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 | |
这套写法的特征:
- 用
CommandBuffer逐条记录命令,最后context.ExecuteCommandBuffer(cmd)提交。 - 相机目标通过
renderingData.cameraData.renderer.cameraColorTargetHandle(RTHandle)拿到。 - 临时 RT 需要自己申请、自己释放(
RenderingUtils.ReAllocateIfNeeded/m_TempRT.Release())。 renderPassEvent决定这个 Pass 在哪个阶段插入。
这套写法的最大问题:管线拿到的只是一串已经排好的命令,它不知道哪个 Pass 读了什么、写了什么,也就无法做资源生命周期复用、Pass 剔除和 Native Render Pass 合并。
3. URP 17 时代的 RenderFeature 写法
URP 17 下,同一个效果翻译成 RenderGraph 写法。先看整体结构:
1 | |
这里体现了 RenderGraph 写法的分工:
RecordRenderGraph()只负责”描述”这个 Pass,不直接记录 GPU 命令。- 资源读写关系在 builder 上声明(
UseTexture/SetRenderAttachment),而不是通过命令顺序隐含。 PassData保存执行阶段需要的句柄和参数。SetRenderFunc()里的静态函数才是真正记录命令的地方,用的是RasterCommandBuffer而不是裸CommandBuffer。- 临时纹理的生命周期完全交给图系统管理,不用手动释放。
4. 一个节点 = 一套附件:切换 RenderTarget 就要拆 Pass
刚接触 RenderGraph 时容易有个疑问:以前一个 SSAO 是一个 Pass,里面还带两次模糊,现在是不是要被拆成好几个 Pass?
先给结论:是的,切换 RenderTarget 就必须新开一个图节点。 但”Pass 数量”其实没有变多,变多的只是代码结构。
这里要先分清两层含义的”Pass”:
- 脚本 Pass(
ScriptableRenderPass):代码组织单元。URP 14 里一个 Pass 内部可以多次SetRenderTarget。 - GPU Render Pass:一次完整的 RT 切换 + Load/Store + 绘制序列。
旧写法里”一个 SSAO Pass 带两次模糊”,在 GPU 上本来就是多个 Render Pass——每次 cmd.SetRenderTarget(另一张RT) 都是一次新的 Render Pass,只是被包在一个 Execute() 里,藏在脚本层看不见:
1 | |
这本来在 GPU 上就是 4 个 Render Pass。RenderGraph 只是把这个事实显式化了:
1 | |
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 拆开了亏不亏
换来三个旧写法做不到的自动优化:
- 临时纹理内存复用:BlurA / BlurB 生命周期不重叠,可复用同一块底层内存。
- Pass 剔除:输出没人读的节点自动干掉。
- 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.cameraColorTargetHandle(RTHandle) |
frameData.Get<UniversalResourceData>().activeColorTexture(TextureHandle) |
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 | |
原因:AddRenderPasses 在目标还没分配时就被调用,而 SetupRenderPasses 在目标分配完成后调用。RenderGraph 路径下资源访问尽量走 frameData,但确实需要 RTHandle 的场景(比如某些兼容逻辑)就要放这里。
6. 相机数据 / 光照数据的获取
旧写法里大量代码依赖 RenderingData 里带出来的结构体,这些在 RenderGraph 路径下改从 ContextContainer 取:
1 | |
1 | |
其他常用数据:
1 | |
frameData.Get<T>() 拿到的对象在整个帧内有效,可以把需要的引用存进 PassData 传给执行函数。
7. 更省事的 Blit:AddBlitPass
如果只是做一个简单的”读一张纹理、用材质处理、写到另一张纹理”的 Pass,不需要手写 AddRasterRenderPass。URP 17 提供了 RenderGraphUtils.AddBlitPass:
1 | |
它会自动生成一个 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 | |
8.2 Compute Pass
屏幕空间效果(HBAO、SSR、Depth Pyramid 这类)常用 ComputeShader。旧写法是 cmd.DispatchCompute,新写法用 AddComputePass:
1 | |
对应关系:AddRasterRenderPass → RasterGraphContext.cmd(RasterCommandBuffer);AddComputePass → ComputeGraphContext.cmd(ComputeCommandBuffer)。还有一种 AddUnsafePass,它的命令缓冲限制最少,迁移过程中可以用它先让旧代码跑起来,再逐步改成 Raster / Compute Pass。
8.3 Hi-Z + SSR:显式引用如何让 RenderGraph 自动剔除
Hi-Z 和 SSR 正好能说明 RenderGraph 的核心价值。假设一帧内有三段工作:
1 | |
旧写法只保证三段命令按顺序提交;RenderGraph 写法还要明确告诉图系统:BuildHiZ 写出的 HiZPyramid,会被 TraceSSR 读取;TraceSSR 写出的 SSRTexture,会被 CompositeSSR 读取。这个关系不是靠变量名、PassData 赋值或 C# 调用顺序推断,而是靠 builder 上的资源声明建立。
下面省略 ComputeShader 的具体参数绑定和线程组计算,只保留决定依赖关系与剔除行为的代码:
1 | |
在 RecordRenderGraph() 中把链条接起来:
1 | |
此时图编译器能从最终的 cameraColor 反向追踪:
1 | |
如果 SSR 的结果没有合成回 resources.cameraColor,也没有被其他有效 Pass 读取,那么 CompositeSSR 不存在或它的输出悬空,TraceSSR 就没有有效消费者;继续反向传播后,只服务于 SSR 的 BuildHiZ 也会一起被剔除。也就是说,剔除不是看到 enableSSR == false 后猜出来的,而是从最终可观察输出反向检查资源依赖得到的。
这里有四个容易混淆的点:
passData.hiZ = hiZ只是把句柄传到执行函数,不会建立图依赖;必须再调用builder.UseTexture(hiZ, AccessFlags.Read)。- 在
SetRenderFunc()里偷偷绑定一个没有通过 builder 声明的纹理,RenderGraph 看不见这次读取,可能错误剔除、错误安排资源生命周期,或直接报资源未声明。 - 如果 Hi-Z 还被 GPU Occlusion、SSGI 等节点读取,即使关闭 SSR,
BuildHiZ也不会被剔除,因为它仍有其他有效消费者。 - 功能明确关闭时,最好直接不记录相关节点;自动剔除是图编译阶段的安全网和优化手段,不应代替清晰的功能分支。
如果通过全局纹理传递 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 | |
拷贝类操作先用 RenderGraphUtils.CanAddCopyPassMSAA() 判断,再决定是否走 AddCopyPass。
9.5 不要把旧 Execute 整段塞进 SetRenderFunc
最常见的”假迁移”:把 Execute() 里所有逻辑原样搬进 SetRenderFunc()。这样可能跑通,但里面那些 GetTemporaryRT、SetRenderTarget、隐式副作用会让 RenderGraph 的优化全部失效。正确划分是:
- 创建临时纹理 →
renderGraph.CreateTexture(); - 声明输入输出 → builder API;
- 真正的绘制命令 →
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 差异
AccessFlags、GetDescriptor(renderGraph)、BlitMaterialParameters 的参数顺序等 API 在不同 URP 小版本有调整。写代码时优先依赖稳定概念(声明 Pass、声明读写、保存数据、设置执行函数),具体的 API 签名以当前包的 Changelog 和官方 Manual 为准。
10. 迁移步骤清单
- 用 Unity 6 打开项目,确认自动进入 Compatibility Mode,先把旧效果跑通作为基准。
- 逐个迁移自定义 Feature:
- 确认
Create()初始化 Pass; AddRenderPasses里保留EnqueuePass,把对 RenderTarget 的访问挪到SetupRenderPasses;- 重写
RecordRenderGraph,先处理 Pass 的输入输出声明,再写执行函数; - 优先用
AddBlitPass/AddCopyPass这类封装,减少漏声明。
- 确认
- 对复杂 Pass,可先用
AddUnsafePass让旧代码过渡跑起来,再换成 Raster / Compute Pass。 - 关闭 Compatibility Mode,重点验证每个自定义 Pass 是否真的执行、效果是否和升级前一致。
- 用 Frame Debugger 看 Pass 顺序,用 RenderGraph Viewer(
Window > Analysis > Render Graph Viewer)看每个 Pass 是执行(绿色)还是被剔除(红色),以及资源生命周期。 - 真机验证,尤其是移动端:关注 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