做 URP 项目的朋友应该都懂,URP 12.x 里想塞一个自定义后处理效果,跟内置管线时代完全是两回事。以前用 OnRenderImage 直接捞画面、改两层 Blit 就完事,换到 URP 之后这套路彻底失灵,官方给的路子是 Renderer Feature 加 ScriptableRenderPass,配合 Volume 参数做动态调节。这篇文章就围绕 URP 12.x 下的自定义后处理单pass渲染,讲清楚接入原理、完整 C# 代码、Shader 侧实现,以及我实际调试时踩过的坑,适合想在 URP 项目里加屏幕特效、又不想依赖第三方后处理插件的开发者。
这里说的“单pass”,不是指只画一个三角形那么简单,而是指整个后处理流程不需要额外申请一张临时 RenderTexture,也不需要先拷到临时 RT、再拷回屏幕这种双 pass Blit。一次 DrawProcedural 直接吃到相机的颜色纹理,然后输出到当前渲染目标。这种写法在移动端尤其值钱,省一次全屏纹理读写,带宽和功耗都更友好。
1. 先理解 URP 12.x 后处理的工作方式:为什么不能再用 OnRenderImage
1.1 内置管线的旧套路,在 URP 里为什么行不通
很多从 Built-in Render Pipeline 转过来的同学,第一反应是找 OnRenderImage。但 SRP 的目标是让开发者能控制渲染流程的每一个节点,URP 把相机渲染过程拆成了一个个 ScriptableRenderPass,由 Renderer 统一调度。OnRenderImage 这种在相机渲染后直接拦截帧缓冲的接口,从根本上和 SRP 的设计冲突,所以直接被移除了。
如果你强行用Camera.onPostRender或者其它旁路手段去动画面,会面临一堆问题:时机不确定、和 URP 内部的 Pass 顺序冲突、在某些图形 API 下还会造成不必要的拷贝。我在项目里见过有人用Graphics.Blit硬塞,结果打开 Depth Texture 之后画面偶发闪烁,排查半天才发现是自己绕过管线导致的。
URP 正确做法是把后处理逻辑写进一个ScriptableRenderPass,再用ScriptableRendererFeature把这个 Pass 注册到渲染流程里。Feature 相当于一个钩子,它决定你的 Pass 在哪个渲染节点前后插入。
1.2 URP 官方扩展点:Renderer Feature 和 Volume 组件
URP 12.x 里,自定义后处理的标准姿势是“Renderer Feature + ScriptableRenderPass + Shader + Volume Component”。Renderer Feature 负责把 Pass 注入渲染队列;Pass 决定何时执行、用哪个材质;Shader 是实际的像素加工逻辑;Volume 则是方便美术在场景里调参的桥梁。
如果你把 URP 渲染流程想象成一条公交线,站点的顺序是固定的:深度预 pass、不透明物体、天空盒、透明物体、后处理、最终上屏。Renderer Feature 就是你在某几站之间申请加入的临时车次。你可以选择在BeforeRenderingPostProcessing时插入,表示在 URP 自带的后处理(Bloom、色调映射等)之前先跑你的效果;也可以选AfterRenderingPostProcessing,表示在 URP 后处理全部完成之后再加工一次。
1.3 单pass渲染到底解决了什么问题
全屏后处理最傻的做法是:从相机颜色缓冲拷贝到一张临时 RT,再对着临时 RT 采样,处理后写到屏幕上。这至少涉及两次全屏纹理操作,一次写入临时 RT,一次读临时 RT。带宽压力很大,尤其手机上的 GPU 核心频率是受限的,这一类傻拷贝是耗电大户。
单pass后处理的做法是:用一个全屏三角形直接覆盖屏幕,在片元着色器里从_CameraColorTexture采样,处理完直接输出到当前 Render Target。整个过程没有临时 RT,没有cmd.Blit,只有一个DrawProcedural。从代码量上看更短,从 GPU 资源占用上看也更干净。
2. 动手前必须确认的环境和资源准备
2.1 URP 12.x 对应的 Unity 版本和项目配置
先说版本。URP 12.x 是 Unity 2021.2 到 2021.3 LTS 这一周期对应的包版本。如果你用的是 Unity 2021.3 LTS,Package Manager 里默认装的 Universal RP 就是 12.1.x 或者 12.0.x。如果你打开项目发现是 URP 14.x,那是 Unity 2022.3 的默认包,部分 API 有变动,本文的写法可以参考,但需要稍微调整。
项目配置方面,必须确保:项目当前使用的渲染管线是 UniversalRenderPipelineAsset,并且在 Graphics Settings 里也指定了同一份 URP Asset。我见过一些人只改了 Project Settings 里的管线,但 Graphics Settings 没改,结果编辑器在某种渲染模式下还能跑,导出后却白屏,这种基础问题最常见。
2.2 准备测试场景、材质和 Shader
建议新建一个空场景,放一个带贴图的 Cube 和两个不同颜色的 Point Light,确保画面有可见内容。后处理效果的验证必须建立在“屏幕上确实有东西”的基础上,否则很可能误判成失效。
然后创建你的后处理材质。材质 Shader 先随便用一个占位 Unlit Shader,后面再换成自定义的。如果你打算用 Renderer Feature 里配置 Shader 自动创建 Material 的方式,可以直接在 Feature 上拖 Shader;如果你习惯手动绑定 Material,也可以直接拖材质。两种方式我都试过,从项目可维护性角度,我更推荐 Feature 里持有 Shader,运行启动时new Material(shader),这样不会因为材质丢失导致整条渲染链断掉。
2.3 两个关键纹理:_CameraColorTexture 和 _CameraDepthTexture
URP 12.x 里,后处理 Shader 读的不是_MainTex,而是 URP 在渲染流程中维护的_CameraColorTexture。这个纹理是 RTHandle 体系的,你可以在 C# 侧通过renderer.GetCameraColorTexture()拿到,再通过cmd.SetGlobalTexture传给 Shader。
_CameraDepthTexture也类似,但它需要你在 URP Asset 的 Rendering 设置里把 Depth Texture 勾上。很多自作后处理的人第一步就倒在这里:Shader 里写了SAMPLE_TEXTURE2D(_CameraDepthTexture, sampler_CameraDepthTexture, uv),结果永远采样不到,因为 URP 根本就没生成深度纹理。这属于低等级错误,但真的很容易踩。
3. 从 Renderer Feature 到单pass渲染:C# 侧完整接入流程
3.1 写一个 ScriptableRenderPass 并注入渲染流程
先创建一个继承ScriptableRenderPass的类。这个类核心是Execute方法,URP 会在约定的渲染节点调用它。
using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class SinglePassPostProcessPass : ScriptableRenderPass { private readonly Material _material; private readonly string _profilerTag; public SinglePassPostProcessPass(Material material, RenderPassEvent evt) { _material = material; _profilerTag = "SinglePassPostProcess"; renderPassEvent = evt; // 告诉 URP:这个 pass 需要读取相机颜色纹理,没有它的话要先生成 ConfigureInput(ScriptableRenderPassInput.Color); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (_material == null) return; CommandBuffer cmd = CommandBufferPool.Get(_profilerTag); RenderTargetIdentifier cameraTarget = BuiltinRenderTextureType.CameraTarget; cmd.SetRenderTarget(cameraTarget); RTHandle colorTexture = renderingData.cameraData.renderer.GetCameraColorTexture(); if (colorTexture != null) { cmd.SetGlobalTexture("_MainTex", colorTexture); } // 画一个全屏三角形,Shader 里用 SV_VertexID 生成顶点,不需要 Mesh cmd.DrawProcedural( Matrix4x4.identity, _material, 0, MeshTopology.Triangles, 3, 1); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } }这里最关键的一行是ConfigureInput(ScriptableRenderPassInput.Color)。没有这一行,URP 不保证_CameraColorTexture在你这个 Pass 执行时可用,结果就是采样出来黑屏或者花屏。我们后续排查问题里会细说。
如果你用的 URP 版本较新,GetCameraColorTexture()返回的可能不是RTHandle而是RenderTargetIdentifier,编译报错时换成colorTexture.nameID或者.rt就行。URP 12.x 里以 RTHandle 为主,但不同小版本之间接口小改是常态。
3.2 创建 Renderer Feature,把后处理挂到 URP 渲染器上
有了 Pass,还需要一个ScriptableRendererFeature来创建它、注册它。Feature 类更像是一个“配置入口”,Unity 编辑器里你在 Renderer Data 组件上能看到它,然后把参数暴露在 Inspector 里。
using UnityEngine; using UnityEngine.Rendering.Universal; [System.Serializable] public class SinglePassPostProcessFeature : ScriptableRendererFeature { [SerializeField] private Shader _shader; [SerializeField] private RenderPassEvent _evt = RenderPassEvent.BeforeRenderingPostProcessing; private Material _material; private SinglePassPostProcessPass _pass; public override void Create() { if (_shader == null) return; _material = new Material(_shader); _pass = new SinglePassPostProcessPass(_material, _evt); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (_material == null || _pass == null) return; renderer.EnqueuePass(_pass); } }然后把SinglePassPostProcessFeature挂到 URP Renderer Data 的 Renderer Features 列表里,拖入 Shader。Create 方法会在运行开始、编辑器重新编译等时机调用,AddRenderPasses 会逐帧被调用,但不要在这里频繁 new Pass,尽量保持同一个实例。
3.3 CommandBuffer 的生命周期和内存管理
CommandBuffer 是 GPU 命令的容器,创建和释放要配对。用CommandBufferPool.Get而不是new CommandBuffer,用完之后Release回池子里,避免每帧堆内存分配。
实际操作里我还会在Execute开头做一层防御判断:如果材质没了、Shader 不支持、或者相机没有后处理权限,直接 return。URP 的 Pass 在编辑器和真机上行为差异很大,这条防御能让问题好找很多。
另一个容易被忽略的点是:不要在Execute里直接改材质参数,除非你用 PerFrame 更新的公共属性。后处理材质如果挂在多个相机上,渲染顺序可能导致参数串台。建议参数更新放在AddRenderPasses或者 Volume 栈读取时统一处理,执行阶段只读不写。
4. Shader 侧实现:全屏三角形和单pass后处理模板
4.1 为什么用三角形而不是四边形
全屏后处理传统做法是画一个大 Quad,两个三角形组成。单pass 渲染里更推荐只画一个三角形,三个顶点刚好覆盖整个视口。这么做的好处:少一个三角形,少 6 个顶点索引,而且大部分 GPU 对覆盖全屏的大三角形有自己的优化路径。
在 Shader 顶点着色器里通过SV_VertexID生成顶点,不需要外部 Mesh,也不需要一个带全屏 Quad 的 Mesh 资源。这在 SRP 体系里是最干净的方式。
Shader "Custom/SinglePassPostProcess" { SubShader { Tags { "RenderPipeline" = "UniversalPipeline" "RenderType" = "Opaque" } Pass { ZWrite Off ZTest Always Cull Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" struct Attributes { uint vertexID : SV_VertexID; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; Texture2D _MainTex; SamplerState sampler_LinearClamp; Varyings vert(Attributes input) { Varyings output; // 由 vertexID 直接生成全屏三角形三个顶点 // (0,0)、(2,0)、(0,2),覆盖视口的同时保证 UV 映射正确 float2 uv = float2((input.vertexID << 1) & 2, input.vertexID & 2); output.positionCS = float4(uv * 2.0 - 1.0, 0.0, 1.0); output.uv = uv; #if UNITY_UV_STARTS_AT_TOP output.uv.y = 1.0 - output.uv.y; #endif return output; } half4 frag(Varyings input) : SV_Target { half4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_LinearClamp, input.uv); return color; } ENDHLSL } } }这里的float2 uv = float2((input.vertexID << 1) & 2, input.vertexID & 2)不要被位运算吓到,它只是用三种顶点 ID 算出(0,0)、(2,0)、(0,2)三个坐标。经过光栅化后,视口内每个片元的 UV 正好处于 0 到 1 范围。这个技巧我在很多 SRP 示例里都在用,比预置 Mesh 省事很多。
4.2 片元着色器里为什么要用采样器宏
我在上面的模板里用了SAMPLE_TEXTURE2D,而不是tex2D。原因很简单:URP 采用可编程混合管线和多种平台,tex2D在部分 SRP 场景下不会自动处理平台差异,而SAMPLE_TEXTURE2D是 core 库里的宏,会根据目标平台展开成正确的 GX、Metal、Vulkan 采样代码。
采样器sampler_LinearClamp必须和纹理一一对应。如果你用了_MainTex的采样器名称不对,hlsl 编译器通常会报错或者输出模糊采样。后处理一般都要Clamp,不要让 UV 超出边缘后采样到外部颜色。
在片元着色器里你可以做任何颜色加工:亮度调整、颜色映射、径向模糊、描边、边缘光。URP 下做 NPR 卡通风格的后处理,也经常走这套流程,比如在BeforeRenderingPostProcessing注入一个描边 Pass,把深度法线纹理算边缘,输出到同一个颜色缓冲。
4.3 材质参数怎么传进 Shader
Material 属性定义在 Shader 的 Properties 块里,然后必须在 HLSL 代码里再次声明。URP 和 SRP Batcher 协同工作时,推荐使用 CBUFFER 包裹材质参数,避免批处理失效。
Properties { _Intensity ("Intensity", Range(0.0, 2.0)) = 1.0 } CBUFFER_START(UnityPerMaterial) float _Intensity; CBUFFER_ENDC# 侧_material.SetFloat("_Intensity", value)即可传值。注意后处理材质通常不参与 SRP Batcher,但保留 CBUFFER 是个好习惯,以后要复用这段 Shader 到普通物体也不会有问题。
5. 实战:用这套方案写一个径向模糊后处理
5.1 效果设计、参数选择
径向模糊的视觉特征是画面从中心向外呈放射状“拖影”,很适合做速度感、镜头聚焦、动漫风的冲击力画面。我们需要四个基础参数:
| 参数 | 类型 | 作用 |
|---|---|---|
| intensity | Float | 整体强度,控制混合程度 |
| radius | Float | 模糊半径,控制采样范围 |
| center | Vector2 | 模糊中心点,默认 (0.5, 0.5) |
| sampleCount | Float | 采样次数,影响性能和画质 |
采样次数不要做成无上限的 int 参数,移动端循环次数一旦上来,GPU 必然跪。建议在 Shader 里写死一个上限,比如 8 到 12 次,C# 侧只允许调强度,不允许调循环上限。
5.2 Shader 实现细节
在模板基础上,加上径向模糊逻辑:
half4 frag(Varyings input) : SV_Target { float2 uv = input.uv; float2 dir = uv - _Center; half4 color = 0; // 固定循环次数,避免平台差异和性能爆炸 for (int i = 0; i < 8; i++) { float t = (float)i / 7.0 * _Radius; color += SAMPLE_TEXTURE2D(_MainTex, sampler_LinearClamp, uv - dir * t); } color /= 8.0; return half4(color.rgb * _Intensity, 1.0); }循环里每次采样都在- dir * t上偏移,越靠近中心采样点越聚集,越远离中心拖影越明显。用half4 color做累加会导致精度问题,但作为示例足够了。强烈建议实际项目里用float4累加,最后再转 half 输出,避免移动端半精度累加产生条带。
5.3 Volume 组件和参数同步
要给美术一个友好的调参界面,需要写一个 VolumeComponent。URP 12.x 里继承VolumeComponent,加上VolumeComponentMenu特性就能出现在 Global Volume 的 Add Override 菜单里。
using UnityEngine; using UnityEngine.Rendering; [System.Serializable, VolumeComponentMenu("Custom Post Process/RadialBlur")] public sealed class RadialBlurVolume : VolumeComponent { public ClampedFloatParameter intensity = new ClampedFloatParameter(0.0f, 0.0f, 2.0f); public ClampedFloatParameter radius = new ClampedFloatParameter(0.02f, 0.0f, 0.1f); public Vector2Parameter center = new Vector2Parameter(new Vector2(0.5f, 0.5f)); }然后在 Renderer Feature 的 AddRenderPasses 里读取 Volume 栈并同步给材质:
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (_material == null || _pass == null) return; if (!renderingData.cameraData.postProcessingEnabled) return; var stack = VolumeManager.instance.stack; var volume = stack.GetComponent<RadialBlurVolume>(); if (volume == null || volume.intensity.value <= 0.0f) return; _material.SetFloat("_Intensity", volume.intensity.value); _material.SetFloat("_Radius", volume.radius.value); _material.SetVector("_Center", volume.center.value); renderer.EnqueuePass(_pass); }这个写法有个隐藏优点:当某个 Volume 把 intensity 调成 0,或者场景里根本没有这个 override 时,Pass 不会被入队,完全省掉一个空跑的渲染节点。
5.4 在场景里调参和验证
在 Hierarchy 里创建 Global Volume,Volume Profile 里 Add Override,选择 Custom Post Process/RadialBlur,然后拖动 intensity 观察画面变化。如果你发现画面完全不变,先不要怀疑 Shader,大概率是前面说的两个点:相机的 Post Processing 没勾,或者 URP Asset 的后处理没开启。
6. 实战中会遇到的坑和排查记录
6.1 黑屏、白屏、画面闪烁排查
后处理开发最常见的三个问题:黑屏、白屏、间歇闪烁。黑屏通常是采样纹理不对,白屏是 Shader 输出写死了或者 RenderTarget 设置错误,闪烁则是 Pass 入队时机和 URP 内部状态冲突。
遇到黑屏,先查ConfigureInput。如果没有声明ScriptableRenderPassInput.Color,URP 可能在你的 Pass 执行时没有生成颜色纹理。遇到白屏,查ZWrite Off ZTest Always是否加上。后处理是全屏覆盖,深度测试必须关掉,否则会因为深度缓冲里已有物体而全部被剔除。
闪烁问题多数出在renderPassEvent选错了。比如你把 Pass 放在AfterRenderingPostProcessing,此时 URP 已经做过一遍 Tonemapping,你再往里写颜色不仅要遵守 HDR 约定,而且要小心是否会覆盖最终目标缓冲。在 URP 12.x 里,自己写后处理最好老老实实放在BeforeRenderingPostProcessing。
6.2 OpenGL、Vulkan、Metal 下的坐标差异
全屏三角形和 UV 的对应关系在不同图形 API 下方向不同。OpenGL 的 NDC 坐标 y 轴朝上,Vulkan 和 Metal 的 Framebuffer 坐标方向又有差异。URP 12.x 在内部用_ProjectionParams和UNITY_UV_STARTS_AT_TOP做了兼容。
我在 Shader 里写了一个条件翻转:
#if UNITY_UV_STARTS_AT_TOP output.uv.y = 1.0 - output.uv.y; #endif如果发现画面上下颠倒,就把这段加上;如果画面颜色位置不对但没颠倒,多半是采样_MainTex的 uv 和实际输出 uv 不一致。另一个更省心的办法:直接用 Unity 官方 Blit.hlsl 里的vert函数,它会处理这些平台差异。不过如果你想像本文这样完全从零写单pass模板,上面这段翻转必须保留。
6.3 URP 12.x 特有的坑:RTHandle、命名、版本迁移
URP 12.x 开始全面使用 RTHandle 系统,以前的 RenderTexture 在很多地方被 RTHandle 替代。cmd.SetGlobalTexture的入参如果是 RTHandle,在部分平台会隐式转换,但最好显式传RTHandle或者RenderTargetIdentifier,不要传Texture。
另外,URP 12.x 的小版本之间 API 也有过调整,比如ScriptableRenderPass的构造函数、Execute的签名在 14 之后才统一改成默认参数。如果你在 Unity 2022.3 上编译本文代码,可能会看到构造函数标记 obsolete 的警告,解法是把renderPassEvent写进构造函数参数,或者直接升级到 URP 14 的新写法。
6.4 性能实测和优化思路
我拿一个 Galaxy S10 级别的设备测过,径向模糊 8 次采样,单pass渲染比双pass Blit 方案每帧少了 1.1ms 左右的 GPU 耗时,而且这还是没算额外 RT 分配的情况下。差异主要来自带宽:单pass 只读一次_CameraColorTexture,写一次目标缓冲;双pass 至少多一次全屏写入和全屏读取。
优化方向有三个。第一,降采样。如果你的效果不需要原生分辨率,可以把输出目标设为半分辨率,但注意这会放弃“单pass 不分配 RT”的优势,性能收益通常还是正的。第二,减少采样次数。径向模糊 8 次和 12 次肉眼看不太出来,但 GPU 差距很大。第三,用 mipmap。降采样后的_CameraColorTexture配合sampler_LinearClamp拉远采样距离,其实等于变相模糊,很多 Bloom 类后处理都这么做。
我在实际项目里的习惯是把耗时的后处理拆成“低分辨率模糊 + 全屏合成”两个阶段,然后再合成阶段用本文的单pass三角形画法,而不是直接用一整个高成本的模糊循环。这样既保住了画质上限,又把单pass的优点用在了刀刃上。
最后再分享一个小技巧。调试后处理效果时,可以先在场景里放一个 Quad,把后处理材质直接赋上去,手动给材质_MainTex拖一张测试贴图。这样你能绕开整个渲染管线,先在普通物体渲染路径里验证 Shader 的算法是否正确,等 Shader 效果满意了再挂回 Renderer Feature。这个习惯帮我省掉了很多“到底是 Shader 错了还是流程错了”的排查时间,建议你也试试。