Unity-URP14升级URP17-RenderFeature适配RenderGraph

把项目从 Unity 2022.3(URP 14)升级到 Unity 6(URP 17)之后,最直观的”爆炸点”就是项目里所有自定义 ScriptableRendererFeature 要么不显示、要么报资源错误。原因很直接:URP 14 里 Execute() 还是渲染 Pass 的主角,URP 17 里管线只认 RecordRenderGraph()

本文用一个最常见的”全屏后处理 Blit Feature”作为例子,从旧写法到新写法逐行对照,顺便把资源获取、Blit、临时 RT、RendererList、Compute 这些常用写法的差异一起说清楚。

1. 版本对应与升级背景

先记住版本对应关系:

URP 版本 Unity 版本
URP 12 Unity 2021.2
URP 13 Unity 2021.3
URP 14 Unity 2022.2 / 2022.3 (LTS)
URP 15 Unity 2023.1
URP 16 Unity 2023.2 / 2023.3
URP 17 Unity 6(6000.x)

URP 17 的关键变化是 RenderGraph 成为唯一的渲染路径。Unity 官方明确表示不再开发不基于 RenderGraph 的渲染路径。

升级时的实际表现:

  • 新创建的 URP 项目默认开启 RenderGraph。
  • 旧项目用 Unity 6 打开后,会自动开启 Compatibility Mode(Render Graph Disabled),让老代码先能跑起来。
  • 兼容模式只是过渡用的,不建议长期保留,因为新特性、优化、新 API 都只会在 RenderGraph 路径上开发。
  • 关闭位置:Edit > Project Settings > Graphics > Render Graph > Compatibility Mode (Render Graph Disabled) 取消勾选。

注意:RecordRenderGraph() 不是 URP 17 才发明的。URP 14 里只要在 URP Asset 上打开 “Enable Render Graph”,自定义 Pass 同样要走 RecordRenderGraph()。所以对大多数”从没开过 RenderGraph 的 URP 14 项目”来说,这次升级本质上就是:Execute() 写法整体翻译成 RecordRenderGraph() 写法

2. URP 14 时代的 RenderFeature 写法

URP 14 默认路径下,自定义效果的标准结构是 ScriptableRendererFeature 管生命周期,ScriptableRenderPass 管具体绘制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public class PostProcessFeature : ScriptableRendererFeature
{
public Material effectMaterial; // 效果材质(手写 shader,属性含 _Intensity)
[Range(0f, 2f)] public float intensity = 1f;

PostProcessPass m_Pass;

public override void Create()
{
m_Pass = new PostProcessPass(effectMaterial, intensity);
}

public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
if (effectMaterial == null)
return;
renderer.EnqueuePass(m_Pass);
}

class PostProcessPass : ScriptableRenderPass
{
readonly Material m_Material;
float m_Intensity;

RTHandle m_Source; // 相机颜色目标
RTHandle m_TempRT; // 手动管理的临时 RT

public PostProcessPass(Material material, float intensity)
{
m_Material = material;
m_Intensity = intensity;
renderPassEvent = RenderPassEvent.AfterRenderingTransparents;
}

// 渲染前调用,从 renderer 拿当前相机的颜色 RTHandle
public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
{
m_Source = renderingData.cameraData.renderer.cameraColorTargetHandle;
}

public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
CommandBuffer cmd = CommandBufferPool.Get();
using (new ProfilingScope(cmd, new ProfilingSampler("PostProcessFeature")))
{
// 申请临时 RT:尺寸跟随相机目标描述符
RenderingUtils.ReAllocateIfNeeded(ref m_TempRT,
renderingData.cameraData.cameraTargetDescriptor, FilterMode.Bilinear);

cmd.SetGlobalFloat("_Intensity", m_Intensity);

// Pass0:相机颜色 -> 临时RT;Pass1:临时RT -> 相机颜色
Blitter.BlitCameraTexture(cmd, m_Source, m_TempRT, m_Material, 0);
Blitter.BlitCameraTexture(cmd, m_TempRT, m_Source, m_Material, 1);
}
context.ExecuteCommandBuffer(cmd);
cmd.Clear();
CommandBufferPool.Release(cmd);
}

public void Dispose() => m_TempRT?.Release();
}
}

这套写法的特征:

  1. CommandBuffer 逐条记录命令,最后 context.ExecuteCommandBuffer(cmd) 提交。
  2. 相机目标通过 renderingData.cameraData.renderer.cameraColorTargetHandleRTHandle)拿到。
  3. 临时 RT 需要自己申请、自己释放(RenderingUtils.ReAllocateIfNeeded / m_TempRT.Release())。
  4. renderPassEvent 决定这个 Pass 在哪个阶段插入。

这套写法的最大问题:管线拿到的只是一串已经排好的命令,它不知道哪个 Pass 读了什么、写了什么,也就无法做资源生命周期复用、Pass 剔除和 Native Render Pass 合并。

3. URP 17 时代的 RenderFeature 写法

URP 17 下,同一个效果翻译成 RenderGraph 写法。先看整体结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.RenderGraphModule;
using UnityEngine.Rendering.RenderGraphModule.Util;
using UnityEngine.Rendering.Universal;

public class PostProcessFeature : ScriptableRendererFeature
{
public Material effectMaterial;
[Range(0f, 2f)] public float intensity = 1f;

PostProcessPass m_Pass;

public override void Create()
{
m_Pass = new PostProcessPass(effectMaterial, intensity);
}

public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
if (effectMaterial == null)
return;
renderer.EnqueuePass(m_Pass);
}

class PostProcessPass : ScriptableRenderPass
{
readonly Material m_Material;
float m_Intensity;

public PostProcessPass(Material material, float intensity)
{
m_Material = material;
m_Intensity = intensity;
renderPassEvent = RenderPassEvent.AfterRenderingTransparents;
}

// PassData:执行阶段的数据桥,句柄 + 参数都放这里
class PassData
{
public Material material;
public float intensity;
public TextureHandle source;
}

public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
{
// 1. 从帧数据取当前相机的颜色目标(代替 cameraColorTargetHandle)
UniversalResourceData resourceData = frameData.Get<UniversalResourceData>();
TextureHandle source = resourceData.activeColorTexture;

// 2. 按源纹理描述创建临时 RT(代替 GetTemporaryRT / ReAllocateIfNeeded)
TextureDesc desc = renderGraph.GetTextureDesc(source);
desc.name = "_PostProcessTemp";
TextureHandle temp = renderGraph.CreateTexture(desc);

// 3. Pass1:效果 Pass,声明"读 source、写 temp"
using (var builder = renderGraph.AddRasterRenderPass<PassData>("PostProcess", out var passData))
{
passData.material = m_Material;
passData.intensity = m_Intensity;
passData.source = source;

// 声明资源读写
builder.UseTexture(source, AccessFlags.Read);
builder.SetRenderAttachment(temp, 0, AccessFlags.Write);

// 这里才是真正记录 GPU 命令的地方
builder.SetRenderFunc(static (PassData data, RasterGraphContext context) =>
{
RasterCommandBuffer cmd = context.cmd;
cmd.SetGlobalFloat("_Intensity", data.intensity);
Blitter.BlitTexture(cmd, data.source, new Vector4(1, 1, 0, 0), data.material, 0);
});
}

// 4. Pass2:把 temp 合成回相机颜色(普通拷贝即可,Blit 的 no-material 重载)
using (var builder = renderGraph.AddRasterRenderPass<PassData>("PostProcessComposite", out var compositeData))
{
compositeData.source = temp;
builder.UseTexture(temp, AccessFlags.Read);
builder.SetRenderAttachment(source, 0, AccessFlags.Write);
builder.SetRenderFunc(static (PassData data, RasterGraphContext context) =>
{
Blitter.BlitTexture(context.cmd, data.source, new Vector4(1, 1, 0, 0), 0, false);
});
}
}
}
}

这里体现了 RenderGraph 写法的分工:

  1. RecordRenderGraph() 只负责”描述”这个 Pass,不直接记录 GPU 命令。
  2. 资源读写关系在 builder 上声明(UseTexture / SetRenderAttachment),而不是通过命令顺序隐含。
  3. PassData 保存执行阶段需要的句柄和参数。
  4. SetRenderFunc() 里的静态函数才是真正记录命令的地方,用的是 RasterCommandBuffer 而不是裸 CommandBuffer
  5. 临时纹理的生命周期完全交给图系统管理,不用手动释放。

4. 一个节点 = 一套附件:切换 RenderTarget 就要拆 Pass

刚接触 RenderGraph 时容易有个疑问:以前一个 SSAO 是一个 Pass,里面还带两次模糊,现在是不是要被拆成好几个 Pass?

先给结论:是的,切换 RenderTarget 就必须新开一个图节点。 但”Pass 数量”其实没有变多,变多的只是代码结构。

这里要先分清两层含义的”Pass”:

  1. 脚本 PassScriptableRenderPass):代码组织单元。URP 14 里一个 Pass 内部可以多次 SetRenderTarget
  2. GPU Render Pass:一次完整的 RT 切换 + Load/Store + 绘制序列。

旧写法里”一个 SSAO Pass 带两次模糊”,在 GPU 上本来就是多个 Render Pass——每次 cmd.SetRenderTarget(另一张RT) 都是一次新的 Render Pass,只是被包在一个 Execute() 里,藏在脚本层看不见:

1
2
3
4
5
Execute():
[RT: AO] 生成AO(读深度/法线)
[RT: BlurA] Blur AO -> BlurA
[RT: BlurB] Blur BlurA -> BlurB
[RT: cameraColor] 合成 AO 回场景颜色

这本来在 GPU 上就是 4 个 Render Pass。RenderGraph 只是把这个事实显式化了:

1
2
3
4
Node1 GenerateAO    读 depth/normal,写 AO
Node2 BlurX 读 AO,写 Temp
Node3 BlurY 读 Temp,写 AO
Node4 Composite 读 AO,写 cameraColor

GPU 上的 Render Pass 数量完全没变。所以不该理解为”从 1 个变 3 个”,而是”本来就该是 4 个,以前被脚本层掩盖了”。

4.1 一个节点能装多少事

图节点 = 一套附件,不等于一次 Draw:

  • 同一套附件上 clear + 画多次 → 一个节点。
  • 多附件输出(MRT):SetRenderAttachment(rt0, 0) + SetRenderAttachment(rt1, 1),SSAO 同时输出 AO + 法线也只是一个节点。
  • 换 RT 采样(blur 的 ping-pong)→ 必须新节点,因为要把上一趟 RT 当纹理读,这本身就是一次完整的 Load/Store 周期,硬件上省不掉。

4.2 拆开了亏不亏

换来三个旧写法做不到的自动优化:

  1. 临时纹理内存复用:BlurA / BlurB 生命周期不重叠,可复用同一块底层内存。
  2. Pass 剔除:输出没人读的节点自动干掉。
  3. Native Render Pass 合并:相邻节点附件相同、中间不把 RT 当纹理采样时,移动 GPU 上会自动合并成一个 native pass,省 Load/Store。blur 每趟都要采样上一趟结果,通常不满足合并条件,但这是硬件限制,旧写法一样省不了。

4.3 减少手写成本

  • 简单 Blit / Copy 用 AddBlitPass / AddCopyPass 一行一个节点,不用手写完整结构。
  • 能合并的附件尽量放同一个节点(MRT)。
  • 旧代码实在不想拆,先 AddUnsafePass 过渡,在 render func 里仍能 SetRenderTarget,但会放弃资源管理与合并优化,只建议迁移期用。

一句话总结:换 RT 就得换节点是对的,但它不是”从 1 个 Pass 变 3 个”,而是”以前藏在脚本里的多次 GPU Pass,现在必须显式写成多个节点”。

5. 核心 API 映射表

旧写法(URP 14 Execute / 非 RG) 新写法(URP 17 RenderGraph)
Execute(ScriptableRenderContext ctx, ref RenderingData rd) RecordRenderGraph(RenderGraph rg, ContextContainer frameData)
renderingData.cameraData.renderer.cameraColorTargetHandleRTHandle frameData.Get<UniversalResourceData>().activeColorTextureTextureHandle
renderingData.cameraData(结构体) frameData.Get<UniversalCameraData>()
renderingData.lightData frameData.Get<UniversalLightData>()
cmd.GetTemporaryRT / RenderingUtils.ReAllocateIfNeeded renderGraph.CreateTexture(desc)
cmd.SetRenderTarget(dst) builder.SetRenderAttachment(dst, 0)
cmd.Blit / Blitter.BlitCameraTexture(cmd, src, dst, mat, pass) Blitter.BlitTexture(cmd, srcHandle, scaleBias, mat, pass)AddBlitPass
cmd.SetGlobalTexture builder.SetGlobalTextureAfterPass(handle, propertyId)
cmd.DrawRendererList builder.UseRendererList(handle) + cmd.DrawRendererList(handle)
CommandBufferPool.Get() + context.ExecuteCommandBuffer(cmd) 直接在 SetRenderFunc 里用 context.cmd,无需手动提交
ConfigureTarget() 在 builder 上声明附件(SetRenderAttachment 等)
cmd.GetTemporaryRT 申请的中间图 renderGraph.CreateTexture 创建的 TextureHandle,图系统管理生命周期
命名空间 UnityEngine.Rendering.Universal 额外需要 UnityEngine.Rendering.RenderGraphModule...RenderGraphModule.Util

Feature 类本身变化不大:Create()AddRenderPasses() + EnqueuePass() 保留。但有一个新方法要注意——SetupRenderPasses()

1
2
3
4
5
public override void SetupRenderPasses(ScriptableRenderer renderer, in RenderingData renderingData)
{
// 渲染目标分配完成、可以安全访问 renderer.cameraColorTargetHandle 的地方。
// 在 AddRenderPasses 里访问 RenderTarget 在 URP 17 会直接报错,要挪到这里。
}

原因:AddRenderPasses 在目标还没分配时就被调用,而 SetupRenderPasses 在目标分配完成后调用。RenderGraph 路径下资源访问尽量走 frameData,但确实需要 RTHandle 的场景(比如某些兼容逻辑)就要放这里。

6. 相机数据 / 光照数据的获取

旧写法里大量代码依赖 RenderingData 里带出来的结构体,这些在 RenderGraph 路径下改从 ContextContainer 取:

1
2
3
4
5
// 旧:RenderingData 结构体
CameraData cameraData = renderingData.cameraData;
Vector3 camPos = cameraData.camera.transform.position;
int w = cameraData.camera.pixelWidth;
int h = cameraData.camera.pixelHeight;
1
2
3
4
5
// 新:ContextContainer(帧数据容器)
UniversalCameraData cameraData = frameData.Get<UniversalCameraData>();
Vector3 camPos = cameraData.worldSpaceCameraPos;
int w = cameraData.pixelWidth;
int h = cameraData.pixelHeight;

其他常用数据:

1
2
3
UniversalLightData lightData     = frameData.Get<UniversalLightData>();
UniversalRenderingData rendering = frameData.Get<UniversalRenderingData>();
UniversalShadowData shadowData = frameData.Get<UniversalShadowData>();

frameData.Get<T>() 拿到的对象在整个帧内有效,可以把需要的引用存进 PassData 传给执行函数。

7. 更省事的 Blit:AddBlitPass

如果只是做一个简单的”读一张纹理、用材质处理、写到另一张纹理”的 Pass,不需要手写 AddRasterRenderPass。URP 17 提供了 RenderGraphUtils.AddBlitPass

1
2
3
4
5
6
7
var blitParams = new RenderGraphUtils.BlitMaterialParameters(
sourceTexture, // TextureHandle
destinationTexture,
effectMaterial,
shaderPassIndex);

renderGraph.AddBlitPass(blitParams, "My Blit Pass");

它会自动生成一个 Blit Pass 并完成资源声明,适合快速搭建,也能避免手写时容易漏声明导致的报错。如果只是想拷贝,直接用 renderGraph.AddCopyPass(source, destination)

需要 using UnityEngine.Rendering.RenderGraphModule.Util; 才能访问 RenderGraphUtils
用 Blitter / AddBlitPass 的材质必须是手写 shader,Shader Graph 的材质与 Blitter 不兼容;材质里采样输入纹理的属性名默认是 _BlitTexture

8. 绘制物体 / Compute 的写法差异

8.1 DrawRendererList

旧的画物体写法里,cmd.DrawRendererList(list) 配合手工创建的 RendererList。RenderGraph 里要先创建 RendererList 句柄再声明:

1
2
3
4
5
6
7
8
9
10
11
RendererListHandle listHandle = renderGraph.CreateRendererList(rendererListDesc);

using (var builder = renderGraph.AddRasterRenderPass<DrawPassData>("DrawGrass", out var passData))
{
passData.rendererList = listHandle;
builder.UseRendererList(listHandle); // 声明使用
builder.SetRenderFunc(static (DrawPassData data, RasterGraphContext context) =>
{
context.cmd.DrawRendererList(data.rendererList);
});
}

8.2 Compute Pass

屏幕空间效果(HBAO、SSR、Depth Pyramid 这类)常用 ComputeShader。旧写法是 cmd.DispatchCompute,新写法用 AddComputePass

1
2
3
4
5
6
7
8
9
10
11
12
using (var builder = renderGraph.AddComputePass<ComputePassData>("HBAO", out var passData))
{
passData.aoTexture = aoTexture;
builder.UseTexture(aoTexture, AccessFlags.ReadWrite);
builder.SetRenderFunc(static (ComputePassData data, ComputeGraphContext context) =>
{
ComputeCommandBuffer cmd = context.cmd;
cmd.SetComputeTextureParam(data.computeShader, data.kernelIndex,
Shader.PropertyToID("_Result"), data.aoTexture);
cmd.DispatchCompute(data.computeShader, data.kernelIndex, w, h, 1);
});
}

对应关系:AddRasterRenderPassRasterGraphContext.cmdRasterCommandBuffer);AddComputePassComputeGraphContext.cmdComputeCommandBuffer)。还有一种 AddUnsafePass,它的命令缓冲限制最少,迁移过程中可以用它先让旧代码跑起来,再逐步改成 Raster / Compute Pass。

8.3 Hi-Z + SSR:显式引用如何让 RenderGraph 自动剔除

Hi-Z 和 SSR 正好能说明 RenderGraph 的核心价值。假设一帧内有三段工作:

1
2
3
CameraDepth ──> BuildHiZ ──> HiZPyramid ─┐
CameraColor / Normal / Depth ────────────┼─> TraceSSR ──> SSRTexture
└──────────────────────────> CompositeSSR ──> CameraColor'

旧写法只保证三段命令按顺序提交;RenderGraph 写法还要明确告诉图系统:BuildHiZ 写出的 HiZPyramid,会被 TraceSSR 读取;TraceSSR 写出的 SSRTexture,会被 CompositeSSR 读取。这个关系不是靠变量名、PassData 赋值或 C# 调用顺序推断,而是靠 builder 上的资源声明建立。

下面省略 ComputeShader 的具体参数绑定和线程组计算,只保留决定依赖关系与剔除行为的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
class HiZPassData
{
public TextureHandle depth;
public TextureHandle hiZ;
public ComputeShader compute;
public int kernel;
}

class SSRPassData
{
public TextureHandle color;
public TextureHandle depth;
public TextureHandle normal;
public TextureHandle hiZ;
public TextureHandle result;
public ComputeShader compute;
public int kernel;
}

TextureHandle RecordHiZ(
RenderGraph renderGraph,
TextureHandle cameraDepth,
ComputeShader compute,
int kernel)
{
TextureDesc desc = renderGraph.GetTextureDesc(cameraDepth);
desc.name = "_HiZPyramid";
desc.msaaSamples = MSAASamples.None;
desc.useMipMap = true;
desc.autoGenerateMips = false;
desc.enableRandomWrite = true;

TextureHandle hiZ = renderGraph.CreateTexture(desc);

using (var builder = renderGraph.AddComputePass<HiZPassData>(
"Build Hi-Z", out var passData))
{
passData.depth = cameraDepth;
passData.hiZ = hiZ;
passData.compute = compute;
passData.kernel = kernel;

// BuildHiZ 读取相机深度,并写入 Hi-Z 的各级 mip。
builder.UseTexture(cameraDepth, AccessFlags.Read);
builder.UseTexture(hiZ, AccessFlags.ReadWrite);

builder.SetRenderFunc(static (HiZPassData data, ComputeGraphContext context) =>
{
// mip0 由 depth 生成;后续 dispatch 读取上一层 mip、写下一层 mip。
// SetComputeTextureParam(...) / DispatchCompute(...)
});
}

return hiZ;
}

TextureHandle RecordSSR(
RenderGraph renderGraph,
TextureHandle color,
TextureHandle depth,
TextureHandle normal,
TextureHandle hiZ,
ComputeShader compute,
int kernel)
{
TextureDesc desc = renderGraph.GetTextureDesc(color);
desc.name = "_SSRTexture";
desc.msaaSamples = MSAASamples.None;
desc.enableRandomWrite = true;
TextureHandle result = renderGraph.CreateTexture(desc);

using (var builder = renderGraph.AddComputePass<SSRPassData>(
"Trace SSR", out var passData))
{
passData.color = color;
passData.depth = depth;
passData.normal = normal;
passData.hiZ = hiZ;
passData.result = result;
passData.compute = compute;
passData.kernel = kernel;

builder.UseTexture(color, AccessFlags.Read);
builder.UseTexture(depth, AccessFlags.Read);
builder.UseTexture(normal, AccessFlags.Read);

// 这一句才是 TraceSSR 对 BuildHiZ 的“显式引用”。
builder.UseTexture(hiZ, AccessFlags.Read);
builder.UseTexture(result, AccessFlags.Write);

builder.SetRenderFunc(static (SSRPassData data, ComputeGraphContext context) =>
{
// 把 color / depth / normal / hiZ / result 绑定给 ComputeShader,
// 然后 DispatchCompute。
});
}

return result;
}

RecordRenderGraph() 中把链条接起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
UniversalResourceData resources = frameData.Get<UniversalResourceData>();

TextureHandle hiZ = RecordHiZ(
renderGraph, resources.activeDepthTexture, hiZCompute, hiZKernel);

TextureHandle ssr = RecordSSR(
renderGraph,
resources.activeColorTexture,
resources.activeDepthTexture,
resources.cameraNormalsTexture,
hiZ,
ssrCompute,
ssrKernel);

// Composite 同样要声明:读 activeColorTexture、读 ssr、写 compositedColor。
TextureHandle compositedColor = RecordSSRComposite(
renderGraph, resources.activeColorTexture, ssr, compositeMaterial);

// 把结果接回 URP 的帧资源。后续管线读取 cameraColor,
// CompositeSSR 因此成为影响最终画面的根节点。
resources.cameraColor = compositedColor;

此时图编译器能从最终的 cameraColor 反向追踪:

1
2
3
4
5
cameraColor 需要 CompositeSSR
CompositeSSR 需要 SSRTexture
SSRTexture 需要 TraceSSR
TraceSSR 需要 HiZPyramid
HiZPyramid 需要 BuildHiZ

如果 SSR 的结果没有合成回 resources.cameraColor,也没有被其他有效 Pass 读取,那么 CompositeSSR 不存在或它的输出悬空,TraceSSR 就没有有效消费者;继续反向传播后,只服务于 SSR 的 BuildHiZ 也会一起被剔除。也就是说,剔除不是看到 enableSSR == false 后猜出来的,而是从最终可观察输出反向检查资源依赖得到的。

这里有四个容易混淆的点:

  1. passData.hiZ = hiZ 只是把句柄传到执行函数,不会建立图依赖;必须再调用 builder.UseTexture(hiZ, AccessFlags.Read)
  2. SetRenderFunc() 里偷偷绑定一个没有通过 builder 声明的纹理,RenderGraph 看不见这次读取,可能错误剔除、错误安排资源生命周期,或直接报资源未声明。
  3. 如果 Hi-Z 还被 GPU Occlusion、SSGI 等节点读取,即使关闭 SSR,BuildHiZ 也不会被剔除,因为它仍有其他有效消费者。
  4. 功能明确关闭时,最好直接不记录相关节点;自动剔除是图编译阶段的安全网和优化手段,不应代替清晰的功能分支。

如果通过全局纹理传递 Hi-Z,则生产者使用 SetGlobalTextureAfterPass(),消费者使用 UseGlobalTexture() 显式声明读取。只在执行函数里调用旧式 SetGlobalTexture(),图系统仍然无法可靠建立依赖。Feature 内部能直接传 TextureHandle 时,优先使用上面的方式,关系最清楚,也最有利于剔除和资源生命周期分析。

9. 常见坑

9.1 Pass 被当成空节点剔除

RenderGraph 会分析依赖。如果某个 Pass 的输出没人读,它会被直接剔除,表现为”效果不显示”。临时排查可以用 builder.AllowPassCulling(false),但生产环境不要依赖它。正确做法是:

  • 正确声明下游读取;
  • Feature 内部优先直接传递 TextureHandle
  • 确实需要全局纹理时,由生产者调用 builder.SetGlobalTextureAfterPass(texture, propertyId),并由 RenderGraph 消费者调用 builder.UseGlobalTexture(propertyId, AccessFlags.Read) 声明读取。

SetGlobalTextureAfterPass() 表达的是全局状态副作用,不等同于普通的“下游读取”。不要为了阻止剔除而随手把临时纹理设为全局纹理,否则会隐藏真正缺失的依赖,还可能限制 RenderGraph 的优化空间。

9.2 “Resource X is not set in pass Y”

每个在命令里触碰到的纹理、Buffer 都要先在 builder 上注册(UseTexture / SetRenderAttachment / UseBuffer 等)。漏一个就报这个错。这也是为什么建议从 AddBlitPass 这类封装 API 开始。

9.3 相机目标拿不到 / 在错误时机访问

  • cameraColorTarget / cameraDepthTarget(不带 Handle)在 URP 17 已废弃,访问会报错。
  • AddRenderPasses 里访问 RenderTarget 会报错,要挪到 SetupRenderPasses
  • 某些 renderPassEvent 注入点,activeColorTexture / activeDepthTexture 可能还没生成。如果依赖深度、法线、运动向量,用 ConfigureInput(ScriptableRenderPassInput.Depth)(或 Color / Motion / Normal)显式请求 URP 先生成。

9.4 MSAA 下的黑屏 / 拷贝问题

相机颜色目标可能带 MSAA,直接把它当输入纹理采样会得到错误结果。后处理里如果对 cameraColor 做 Blit,通常需要:

1
requiresIntermediateTexture = true;   // 在 Pass 构造函数里设置

拷贝类操作先用 RenderGraphUtils.CanAddCopyPassMSAA() 判断,再决定是否走 AddCopyPass

9.5 不要把旧 Execute 整段塞进 SetRenderFunc

最常见的”假迁移”:把 Execute() 里所有逻辑原样搬进 SetRenderFunc()。这样可能跑通,但里面那些 GetTemporaryRTSetRenderTarget、隐式副作用会让 RenderGraph 的优化全部失效。正确划分是:

  1. 创建临时纹理 → renderGraph.CreateTexture()
  2. 声明输入输出 → builder API;
  3. 真正的绘制命令 → SetRenderFunc

资源关系在外面,命令记录在里面。

9.6 TextureHandle 只在当帧有效

TextureHandle 是图内的资源引用,跨帧保存没有意义。跨帧的历史数据(TAA history、SSR history、上一帧深度)要用 URP 的 history 机制(UniversalCameraData.historyManager)来管理,再在每帧接入图。

9.7 直接写回相机目标 / 后处理事件点

  • 把效果直接写回 activeColorTexture 时要小心 MSAA 和 backbuffer 限制。
  • AfterRenderingPostProcessing 这个事件在相机没开后处理时不会执行;如果你习惯把所有自定义效果挂在这里,升级后检查相机是否开启后处理。
  • 更好的替代:效果写到新纹理后,通过 UniversalResourceData resourceData = frameData.Get<UniversalResourceData>() 取得帧资源,再设置 resourceData.cameraColor = resultTexture,让后续 Pass 从新纹理继续,省掉一次回写拷贝。

9.8 小版本 API 差异

AccessFlagsGetDescriptor(renderGraph)BlitMaterialParameters 的参数顺序等 API 在不同 URP 小版本有调整。写代码时优先依赖稳定概念(声明 Pass、声明读写、保存数据、设置执行函数),具体的 API 签名以当前包的 Changelog 和官方 Manual 为准。

10. 迁移步骤清单

  1. 用 Unity 6 打开项目,确认自动进入 Compatibility Mode,先把旧效果跑通作为基准。
  2. 逐个迁移自定义 Feature:
    • 确认 Create() 初始化 Pass;
    • AddRenderPasses 里保留 EnqueuePass,把对 RenderTarget 的访问挪到 SetupRenderPasses
    • 重写 RecordRenderGraph,先处理 Pass 的输入输出声明,再写执行函数;
    • 优先用 AddBlitPass / AddCopyPass 这类封装,减少漏声明。
  3. 对复杂 Pass,可先用 AddUnsafePass 让旧代码过渡跑起来,再换成 Raster / Compute Pass。
  4. 关闭 Compatibility Mode,重点验证每个自定义 Pass 是否真的执行、效果是否和升级前一致。
  5. 用 Frame Debugger 看 Pass 顺序,用 RenderGraph Viewer(Window > Analysis > Render Graph Viewer)看每个 Pass 是执行(绿色)还是被剔除(红色),以及资源生命周期。
  6. 真机验证,尤其是移动端:关注 Render Target 的 Load/Store、带宽和 Tile Pass 是否被打断。

总结

URP 14 升级到 URP 17,对自定义 Feature 而言不是”换个 API 拼拼图”,而是把渲染代码从命令式翻译成声明式

  • Execute() 变成 RecordRenderGraph()
  • RTHandle 变成 TextureHandle
  • CommandBuffer 变成 RasterCommandBuffer / ComputeCommandBuffer
  • 临时资源从”自己申请释放”变成”声明给图系统管理”;
  • 数据访问从 RenderingData 结构体变成 frameData.Get<T>()

写 Pass 时记住一个边界:先在外面把资源关系说清楚,再到 SetRenderFunc 里记命令。理解这条边界,迁移就不是一坨看不懂的新 API,而是一种更有结构、也更容易被管线优化的写法。

参考资料

Unity Manual:Upgrade to URP 17 (Unity 6.0)

Unity Manual:Write a render pass using the render graph system in URP

Unity Manual:Blit using the render graph system in URP

Unity Manual:Frame data textures reference for URP

Unity Manual:Get data from the current frame in URP

URP 14 示例:Blit Camera color texture to RTHandle

Class UniversalCameraData | URP 17.1.0

Core RP Library:Blitter.BlitTexture

相关文章:Unity-RenderGraph机制分析