URP底下想做自定义后处理,很多人第一反应是被卡住:OnRenderImage没了,CommandBuffer也跟Built-in时代不太一样。这个坑我踩过好几轮,最后沉淀下来一套最顺手的方案——ScriptableRendererFeature + ScriptableRenderPass + 自定义Shader,把整个后处理收敛成一次单pass渲染。这篇文章围绕URP 14.x(Unity 2022.3 LTS),把从思路到代码、再到平台兼容性的完整链路讲清楚,适合刚接触SRP的客户端开发、美术转TA,以及需要在项目里快速落地自定义屏幕效果的团队。
1. URP自定义后处理:为什么不能再用OnRenderImage
1.1 OnRenderImage失效后,后处理该由谁接管
在Built-in渲染管线里,后处理的写法非常简单:挂一个继承MonoBehaviour的脚本,重写OnRenderImage,拿source纹理,用Graphics.Blit到destination就完事。这套流程看起来天经地义,但到了URP里直接失效,因为URP基于SRP(Scriptable Render Pipeline)架构,渲染流程的所有阶段都是可编程的,不再依赖MonoBehaviour的生命周期钩子。
URP把每一帧的渲染拆成了一个个可以被“注入”的节点,这个注入点就是Renderer Feature。你可以把后处理理解成一趟列车:URP的ScriptableRenderer是列车本身,相机颜色缓冲是乘客,而Feature就是中途加挂的车厢。车厢挂在什么位置、里面做什么事,完全由开发者控制。这个设计比OnRenderImage更灵活,但也意味着不能再偷懒——至少要把“如何拿到当前帧画面”这件事想明白。
URP 14.x对应的Unity 2022.3 LTS,内置的Renderer Feature体系已经相当成熟。我在项目里常用的是自定义的ScriptableRendererFeature,而不是挂在Camera上的脚本,原因是Feature的生命周期和渲染管线绑定在一起,不会出现脚本还在、管线已经切换导致的兼容性问题。
1.2 单pass渲染到底“单”在哪里
先厘清一个概念:单pass渲染里的“单”,指的是一次完整的RenderPass内完成从“源纹理读取”到“效果输出”的闭环,而不是把整条后处理拆成好几个Pass互相倒腾。
我在网上看到过一些从Built-in迁移过来的教程,写后处理时喜欢把一个效果拆成三四个Pass:先模糊、再边缘检测、再合并,最后又搞一个Pass做颜色修正。这种多Pass链在PC上问题不大,但在移动端,尤其是Tile-Based GPU(Adreno、Mali、Apple GPU)上,代价很高。每多一次RT切换,GPU就要把当前帧的Tile数据从片上内存写回显存,再重新加载,带宽和功耗都会显著上升。单pass的意义,说白了就是尽量减少“把中间结果存出去”的次数。
URP里做单pass后处理的标准姿势是:先申请一块临时RT作为中转,用一次Blit把相机颜色拷贝进去,再用自定义材质把临时RT处理回相机颜色目标。两次Blit加在一起仍然只是一个RenderPass内的两条Command,不涉及多Pass的渲染路径切换,这跟多pass渲染有本质区别。这样做虽然多了一次拷贝,但胜在稳定、跨平台,至少不会因为读写同一个RT导致奇怪的GPU告警。
1.3 方案选型:Feature + Pass + Blit
URP 14.x其实自带了一个Fullscreen Pass Renderer Feature,但我在实际项目中很少直接用,原因有两个:
一是它把参数暴露和Shader变体管理封装得太死,动效阶段我想临时加个全局噪声纹理或调试用的强度系数,都得去翻源码改内部类;二是自定义Feature更利于团队协作,所有人看到的是一个“项目自己的后处理入口”,通过编辑器面板就能完成参数注入。
所以我的最终选型是:ScriptableRendererFeature负责管理Pass的创建和入队,ScriptableRenderPass负责具体执行Blit,Shader只负责效果算法。这个结构最大的好处是分层清晰,甚至可以把Pass复用到不同Feature上。下面直接把核心代码贴出来,这一段是经过Unity 2022.3 + URP 14.x验证的。
2. 核心代码:Feature、Pass和Shader
2.1 搭建ScriptableRendererFeature骨架
Feature的职责很单一:在Create()里new出Pass实例,在AddRenderPasses()里把它挂到当前Renderer上。注意一点,Create()不是每帧调用,它只在编辑器脚本重载、Feature被添加到Renderer上时执行,所以不要在Create里做依赖当前相机参数的事情。
using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public sealed class CustomPostProcessFeature : ScriptableRendererFeature { [System.Serializable] public sealed class Settings { public Material postProcessMaterial; [Range(0f, 1f)] public float intensity = 1f; public RenderPassEvent injectionPoint = RenderPassEvent.AfterRenderingTransparents; } public Settings settings = new Settings(); private CustomPostProcessPass m_Pass; public override void Create() { m_Pass = new CustomPostProcessPass(settings); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (settings.postProcessMaterial == null) { return; } renderer.EnqueuePass(m_Pass); } }这里有个容易被忽略的细节:AddRenderPasses里如果直接判断材质为空就不进队列,会导致在Inspector里刚拖上材质但没生效时完全无反馈。我一般会在材质为空时打一条Warning日志,告诉开发者“后处理Feature已启用但材质未赋值”,排查起来会快很多。这个习惯帮我在团队里省了不少答疑时间。
2.2 用ScriptableRenderPass控制Blit
Pass是真正干活的类。OnCameraSetup里申请临时RT并锁定目标,Execute里执行Blit,OnCameraCleanup里释放临时RT。这套三段的生命周期是URP的规范,顺序不要搞反。
using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public sealed class CustomPostProcessPass : ScriptableRenderPass { private const string ProfilerTag = "CustomPostProcessPass"; private readonly CustomPostProcessFeature.Settings m_Settings; private RenderTargetIdentifier m_CameraColorTarget; private int m_TempRTID; private RenderTargetIdentifier m_TempRT; public CustomPostProcessPass(CustomPostProcessFeature.Settings settings) { m_Settings = settings; renderPassEvent = settings.injectionPoint; m_TempRTID = Shader.PropertyToID("_CustomPostProcessRT"); } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { var cameraData = renderingData.cameraData; m_CameraColorTarget = cameraData.renderer.cameraColorTargetHandle; var desc = cameraData.cameraTargetDescriptor; desc.depthBufferBits = 0; desc.msaaSamples = 1; cmd.GetTemporaryRT(m_TempRTID, desc, FilterMode.Bilinear); m_TempRT = new RenderTargetIdentifier(m_TempRTID); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { var cmd = CommandBufferPool.Get(ProfilerTag); if (m_Settings.postProcessMaterial != null) { cmd.SetGlobalFloat(Shader.PropertyToID("_Intensity"), m_Settings.intensity); cmd.Blit(m_CameraColorTarget, m_TempRT); cmd.Blit(m_TempRT, m_CameraColorTarget, m_Settings.postProcessMaterial, 0); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public override void OnCameraCleanup(CommandBuffer cmd) { if (m_TempRTID != 0) { cmd.ReleaseTemporaryRT(m_TempRTID); } } }关键点有三个:
第一,cameraTargetDescriptor拿到的描述符可能带了深度缓冲和MSAA样本数,后处理阶段不需要深度,所以把depthBufferBits置0、msaaSamples置1。如果不强制置1,在相机开了MSAA时,临时RT也会变成MSAA RT,后面的Blit采样结果会和你预期的不一样。
第二,cmd.Blit的第一个参数会成为Shader里_BlitTexture的输入源。也就是说,第二步Blit用材质处理时,Shader里采样_BlitTexture拿到的就是临时RT里的原图信息。这是理解整个流程的钥匙。
第三,CommandBufferPool.Get(ProfilerTag)里的字符串会显示在Frame Debugger里,命名得好,调试时一眼就能定位到这个Pass到底是哪一步。
2.3 编写一个可用的后处理Shader
后处理Shader和普通表面Shader最核心的区别在于:它不需要光照模型,直接采样输入纹理,做屏幕空间的计算。顶点着色器不需要做传统意义上的模型变换,直接把全屏quads的顶点坐标透传成裁剪坐标即可。
这里以一个卡通色调分离(Color Quantize)效果为例,这也是NPR卡通渲染里很常见的后处理手段:
Shader "Hidden/Custom/PostProcess/NPRColorQuantize" { SubShader { Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" } Pass { Name "NPRColorQuantize" ZWrite Off ZTest Always Cull Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" TEXTURE2D(_BlitTexture); SAMPLER(sampler_BlitTexture); half _Intensity; struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings vert(Attributes input) { Varyings output; output.positionCS = float4(input.positionOS.xy, 0.0, 1.0); output.uv = input.uv; #if UNITY_UV_STARTS_AT_TOP if (_ProjectionParams.x < 0) { output.uv.y = 1.0 - output.uv.y; } #endif return output; } half4 frag(Varyings input) : SV_Target { half4 color = SAMPLE_TEXTURE2D(_BlitTexture, sampler_BlitTexture, input.uv); half luminance = dot(color.rgb, half3(0.299, 0.587, 0.114)); half quantized = round(luminance * 3.0) / 3.0; half3 nprColor = color.rgb * quantized; return half4(lerp(color.rgb, nprColor, _Intensity), color.a); } ENDHLSL } } }这段Shader里有几个细节要单独拎出来说:
顶点着色器里output.positionCS = float4(input.positionOS.xy, 0.0, 1.0)是一种非常标准的后处理写法。因为URP在执行Blit时已经绑定了覆盖屏幕的四边形,顶点自带对角线坐标,直接输出就能铺满屏幕,不需要经过任何VP矩阵变换。
UNITY_UV_STARTS_AT_TOP这个宏在DX和Metal平台上会定义,在OpenGL类平台上不会。配合_ProjectionParams.x < 0判断就能解决经典的上下翻转问题。这个我会在后面的平台章节重点展开,这里先保证项目在Windows上跑起来是对的。
_Intensity是从C#端用cmd.SetGlobalFloat传进来的全局参数,所以Shader里不需要单独再暴露Material Property,直接声明同名变量即可。
2.4 在URP Asset里挂载和调试
代码写完后,挂载步骤很简单,但新手最容易卡在这一步:
- 选中项目的URP Asset(Pipeline Asset),在Inspector里找到Renderer List,点击它下面的URP Renderer Data。注意如果相机上指定了其他Renderer Data,你得改的是相机指定的那个。
- 打开这个Renderer Data的Inspector,在Renderer Features列表里点击Add Renderer Feature,选择CustomPostProcessFeature。
- 把刚才写的材质赋给Feature的Post Process Material字段。
- 调整Intensity,Game视图应该立刻能看到卡通色调分离的效果。
挂载后如果没看到效果,优先打开Frame Debugger。在URP的帧调试窗口里,你能看到一个叫“CustomPostProcessPass”的节点出现在AfterRenderingTransparents之后,点进去能看到两次Blit的完整记录。我遇到过的绝大多数问题,在这个窗口里都能当场定位:是Pass没入队,还是材质没赋值,还是Shader编译报错,一眼就清楚。
3. 实操:让后处理真正跑起来
3.1 做一个NPR风格的亮度分离滤镜
前面那个Shader已经能实现最基础的NPR色调分离了,实际操作时我建议先把它调成一个“看得见明显变化”的效果再逐步收敛。比如把quantized从分成3级改成分成4级,或者把亮度阈值用step函数硬切,会让卡通感更强。
如果发现整个画面暗部糊成一团,通常是色调分离的阶梯数太少了。亮度只有0~1,分成3级意味着只有0、0.33、0.66、1四种亮度值,暗部细节大量丢失属于正常现象。分级越多,效果越平滑,但卡通感也会变弱。这里没有标准答案,完全看你项目的风格参考图。
我在一个二次元风格的项目里,最终用了非均匀分级:亮部每0.1一级,暗部每0.3一级。这样既能保留高光细节的层次,又能让暗部显得更“平”。这套参数来自不停对着美术参考调,没有捷径。
3.2 运行时动态调节参数
在编辑器里调参只是第一步,真正做项目时,后处理参数要能跟随玩法动态变化。比如角色进入某个“觉醒状态”时,屏幕饱和度逐渐降低,边缘亮度增强。这种需求用Global参数天然就是为这种情况设计的。
由于上面C#端用了cmd.SetGlobalFloat("_Intensity", intensity),你可以在任何逻辑代码里通过Shader.SetGlobalFloat随时改变强度值。更规范一点的做法是让Feature在Update或值变化时再Set一次,但后处理是一个每帧渲染的过程,直接全局设置是安全的。
如果你的项目用了Volume框架,想用Volume Profile来控制后处理参数,那就要写一个继承VolumeComponent的脚本,然后在Pass的Execute里通过VolumeManager.instance.stack.GetComponent<MyComponent>()取值。这一步会让代码复杂一些,但好处是美术可以在场景里用Volume插值调参,不需要动代码。团队里如果有专职TA,我强烈建议走这条路。
3.3 从色调分离扩展到边缘描边和模糊
同一个框架里,换一个Shader就能得到完全不同的效果,这也体现了Feature + Material解耦的好处。
边缘检测后处理常见的做法是用深度法线纹理(Camera Depth Texture)计算Sobel算子。URP需要在URP Asset里勾选Depth Texture选项,然后在Shader里includeDeclareDepthTexture.hlsl,通过SampleSceneDepth(uv)拿到深度值。两个方向的差分就能得到边缘强度。这个在NPR项目中非常实用,可以做出类似动漫番剧的描边感,且不需要额外建模辅助线。
高斯模糊则是把Blit的目标RT当成输入,在fragment里采样周围N个像素做加权平均。注意模糊半径不宜盲目加大,移动端上9个权重采样点已经很吃ALU了。如果要做大范围模糊,更好的选择是在Blit前先把RT降采样一半,模糊完再升采样回去,视觉差异几乎看不出来,但性能能省一半以上。
4. 平台差异、性能与常见问题排查
4.1 OpenGL的UV翻转是个老坑
后处理Shader最常见的问题就是“Windows上好好的,打包到Android和Mac上画面上下颠倒”。这个问题的根源在于不同图形API的纹理原点约定不一样。DX和Metal系列的纹理坐标系原点在左上,OpenGL的原点在左下,而Unity的屏幕空间UV约定在所有平台上都以“底部为0”为基准。
当你的后处理Blit命令把源纹理绑定给_BlitTexture时,URP内部会根据渲染目标自动处理一部分差异,但遇到OpenGL设备还是有概率踩中翻转。
我在顶点着色器里已经写了一段修正:
#if UNITY_UV_STARTS_AT_TOP if (_ProjectionParams.x < 0) { output.uv.y = 1.0 - output.uv.y; } #endif这段的逻辑是:只有定义了UNITY_UV_STARTS_AT_TOP的平台(DX、Metal)才需要检查翻转,而_ProjectionParams.x < 0表示当前投影矩阵是翻转过的(即渲染到RT的场景)。两个条件叠加,就能把DX平台在RT渲染时的UV修正回来。OpenGL平台不进入这个分支,不会出错。
如果你在某个特定设备上仍然看到上下颠倒,先别怀疑Shader,检查一下是不是_ProjectionParams的语义被别的Pass影响到了。还有一种写法是用_BlitTexture_TexelSize.y < 0来判断,但需要额外在Shader里显式声明float4 _BlitTexture_TexelSize;,不如当前方案省事。
4.2 后处理时机与透明物体的微妙关系
我推荐的注入点是AfterRenderingTransparents,也就是在场景所有不透明和透明物体都画完之后再执行后处理,这时候相机颜色RT里是完整的场景画面。这个时机最直观,适合绝大部分全屏效果。
但要注意,如果你的项目同时开启了URP内置的后处理(Bloom、ToneMapping等),自定义后处理在这个时机执行时,还没有经过内置后处理的调色。同样的效果放在BeforeRenderingPostProcessing和AfterRenderingTransparents,最终颜色会有可感知的差别。我通常在项目初期就会定好“自定义后处理在内置后处理之前还是之后”的规范,否则后期美术调色时来回折腾很痛苦。
透明物体的问题则更隐蔽:如果你把注入点放在BeforeRenderingTransparents,那时透明物体还没画,后处理不会作用于半透物体。如果美术默认逻辑是所有物体都吃后处理,就会出现“只有半透物体没有滤镜”的Bug。这种问题用Frame Debugger看一遍渲染顺序就能查清楚,但提前约定注入点能省掉这些沟通成本。
4.3 移动端性能优化三原则
移动端跑后处理,我的经验可以浓缩成三句话:减少临时RT数量,避免多pass,能降精度就降精度。
第一个原则对应的是不要在一帧里申请多个GetTemporaryRT。我见过一个后处理脚本同时申请了3张半屏RT去做景深,在高端机上勉强能跑,在Mali G78上直接掉到30帧以下。改成单RT复用后,帧耗时直接砍半。
第二个原则是能在一个Pass里写完的算法,不要拆成两个Blit。比如“边缘光 + 色调分离”你完全可以在一个fragment里先采样原图、再采样深度纹理、最后一起算。拆成两个Pass不仅增加RT切换,还会多一次全屏纹理采样。
第三个原则在Shader里体现为尽量使用half精度,采样结果也用half4接收。移动端GPU对half精度的运算吞吐接近float的两倍。这也是我在上面的Shader里沿用half luminance而不是float luminance的原因。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 完全没有效果 | Feature未挂载或材质为空 | 在Renderer Data里检查Feature,分配材质 |
| 只在Game视图有效,Scene视图看不到 | Scene视图默认不启用后处理 | Inspector中打开Scene View的Post Processing开关 |
| 画面上下颠倒 | 平台UV坐标差异未处理 | 在vert里用UNITY_UV_STARTS_AT_TOP配合_ProjectionParams.x修正 |
| 画面边缘有奇怪颜色 | 未处理平台flip导致采样偏移 | 同样的flip处理,建议在材质调试下逐平台验证 |
| 透明物体不受影响 | 注入点早于Transparent渲染 | 使用AfterRenderingTransparents或之后的事件 |
| 效果在内置后处理前后颜色不一致 | 注入点与内置后处理相对顺序未约定 | 根据项目规范选择Before/AfterRenderingPostProcessing |
| 移动端帧率骤降 | 多RT、多Pass、高精度计算 | 检查临时RT数量,合并效果,改用half精度 |
| MSAA开着的相机效果异常 | 临时RT带MSAA样本 | OnCameraSetup里强制desc.msaaSamples = 1 |
5. 一点扩展:从单pass到复杂效果链
5.1 多效果组合不一定要做多Pass
很多团队拿到一个能用的自定义后处理之后,第一反应是继续加Feature,加Pass,最终RenderPassEvent的队列里挂了一长串后处理节点。这样虽然能用,但每个效果都要申请RT、Blit、再恢复RT,性能损耗成倍增长。
更好的思路是做一个“多效果合并”的后处理材质。比如把色调分离、边缘检测、暗角三件事全部放进同一个fragment,通过不同的权重参数控制每种效果的强度。一次Blit,一次RT切换,所有的效果全部叠加完毕。这里的代价是Shader的ALU会变高,但移动端ALU再怎么高,通常也远没有RT切换和带宽消耗那么致命。
我的经验法则是:如果两个效果的采样输入相同(都是原图+深度),就尽量合并在一个Pass里;如果某个效果需要先处理完第一个效果的结果才能继续,那就没办法,只能做链式Pass。链式Pass也别用全屏RT一张张倒,试试Ping-Pong两张RT交替,比链式创建临时RT干净得多。
5.2 和NPR方向结合:后处理是卡通渲染的最后一公里
现在很多团队都在做NPR卡通渲染,大家热衷于打磨头发Shader、描边、脸部的SDF贴图,但容易忽略后处理在风格化中的粘合作用。头发Shader再精致,如果整屏的色彩分级、色调映射和画面边缘处理不到位,卡通感仍然会差一口气。
我参与过一个二次元项目的收尾阶段,美术方向已经定了,角色头发用了高光的各向异性近似,脸部的NDL映射也调了很久,但角色站到场景里总是感觉“跟背景不是一张画”。最后我们就是在HDRP和URP的两个版本里分别加了一个全局后处理:统一的亮度分离、轻微的边缘增强、以及一层非常薄的暖色LUT。几天时间,画面整体性肉眼可见地提升了,而代价只不过是一个单pass的Blit。
如果你也在做NPR方向的项目,建议在后处理阶段重点尝试这几件事:亮度量化、局部对比度增强、边缘暗角、LUT色彩映射。这些效果都是标准的屏幕空间操作,不需要改变角色材质,却能把整个画面的风格统一起来。而且它们因为不需要逐物体计算,性能开销非常可控。
6. 最后想说的
URP 14.x的自定义后处理没有想象中复杂,但有一点经验和大家分享:先理解SRP的渲染流程,再动手写代码。网上很多教程一上来就贴Feature和Pass的代码,确实能用,但遇到平台差异和新需求时就容易懵。我自己就是从一段只能跑的代码开始,逐步搞懂了Blit的原理、RT的生命周期、RenderPassEvent的注入时机,才真正做到对接各种项目都心里有底。
这套框架在Unity 2022.3 LTS上是稳定的,后面即使你升级到Unity 6或HDRP,渲染管线的设计思路依然是同一个套路:Feature负责注入,Pass负责执行,Shader负责算法。早一点把这个思维模型建立起来,后面的技术迭代只会越来越顺畅。