ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

URP自定义后处理指南:Feature+Pass+Shader单pass实现与平台兼容性

URP自定义后处理指南:Feature+Pass+Shader单pass实现与平台兼容性 URP底下想做自定义后处理很多人第一反应是被卡住OnRenderImage没了CommandBuffer也跟Built-in时代不太一样。这个坑我踩过好几轮最后沉淀下来一套最顺手的方案——ScriptableRendererFeature ScriptableRenderPass 自定义Shader把整个后处理收敛成一次单pass渲染。这篇文章围绕URP 14.xUnity 2022.3 LTS把从思路到代码、再到平台兼容性的完整链路讲清楚适合刚接触SRP的客户端开发、美术转TA以及需要在项目里快速落地自定义屏幕效果的团队。1. URP自定义后处理为什么不能再用OnRenderImage1.1 OnRenderImage失效后后处理该由谁接管在Built-in渲染管线里后处理的写法非常简单挂一个继承MonoBehaviour的脚本重写OnRenderImage拿source纹理用Graphics.Blit到destination就完事。这套流程看起来天经地义但到了URP里直接失效因为URP基于SRPScriptable 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 GPUAdreno、Mali、Apple GPU上代价很高。每多一次RT切换GPU就要把当前帧的Tile数据从片上内存写回显存再重新加载带宽和功耗都会显著上升。单pass的意义说白了就是尽量减少“把中间结果存出去”的次数。URP里做单pass后处理的标准姿势是先申请一块临时RT作为中转用一次Blit把相机颜色拷贝进去再用自定义材质把临时RT处理回相机颜色目标。两次Blit加在一起仍然只是一个RenderPass内的两条Command不涉及多Pass的渲染路径切换这跟多pass渲染有本质区别。这样做虽然多了一次拷贝但胜在稳定、跨平台至少不会因为读写同一个RT导致奇怪的GPU告警。1.3 方案选型Feature Pass BlitURP 14.x其实自带了一个Fullscreen Pass Renderer Feature但我在实际项目中很少直接用原因有两个一是它把参数暴露和Shader变体管理封装得太死动效阶段我想临时加个全局噪声纹理或调试用的强度系数都得去翻源码改内部类二是自定义Feature更利于团队协作所有人看到的是一个“项目自己的后处理入口”通过编辑器面板就能完成参数注入。所以我的最终选型是ScriptableRendererFeature负责管理Pass的创建和入队ScriptableRenderPass负责具体执行BlitShader只负责效果算法。这个结构最大的好处是分层清晰甚至可以把Pass复用到不同Feature上。下面直接把核心代码贴出来这一段是经过Unity 2022.3 URP 14.x验证的。2. 核心代码Feature、Pass和Shader2.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控制BlitPass是真正干活的类。OnCameraSetup里申请临时RT并锁定目标Execute里执行BlitOnCameraCleanup里释放临时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 AssetPipeline Asset在Inspector里找到Renderer List点击它下面的URP Renderer Data。注意如果相机上指定了其他Renderer Data你得改的是相机指定的那个。打开这个Renderer Data的Inspector在Renderer Features列表里点击Add Renderer Feature选择CustomPostProcessFeature。把刚才写的材质赋给Feature的Post Process Material字段。调整IntensityGame视图应该立刻能看到卡通色调分离的效果。挂载后如果没看到效果优先打开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.GetComponentMyComponent()取值。这一步会让代码复杂一些但好处是美术可以在场景里用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 15. 一点扩展从单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负责算法。早一点把这个思维模型建立起来后面的技术迭代只会越来越顺畅。
返回列表