首页 / 资讯中心 / 文章详情

URP自定义后处理流程:从框架到性能优化的完整指南

URP自定义后处理流程:从框架到性能优化的完整指南 ★ FEATURED ARTICLE
1. 从一次画面发灰的调试经历说起前阵子帮一个朋友看他们团队做的一个场景Demo画面整体发灰、对比度上不去明明美术在材质和灯光上花了不少功夫最后呈现出来的效果却像蒙了一层雾。我打开他们的工程一看问题出在后处理链路上——他们用的是URP自带的Volume后处理但自定义的几组效果全塞在了一个Renderer Feature里顺序混乱还和内置的Tonemapping、Color Grading打架。这件事让我意识到很多做URP项目的人对自定义后处理流程的理解还停留在能跑就行的阶段一旦效果叠加多了就完全失控。这篇内容我想把URP自定义后处理这件事从头到尾捋一遍。不是简单贴几段Shader代码就完事而是把整个流程的设计思路、为什么这么设计、实际落地时会遇到哪些坑都摊开来讲。适合已经用过URP基础渲染、想进一步掌控画面表现的人也适合那些被后处理顺序折磨过的开发者。核心关键词就一个URP自定义后处理流程——重点在流程两个字而不是单个效果。先说清楚一个前提URP的后处理体系和内置管线完全不同。内置管线时代大家习惯用OnRenderImage加一个Blit就搞定简单粗暴。URP把这套东西拆成了Volume框架加Renderer Feature两层结构灵活度上去了但心智负担也上去了。很多人第一次接触会懵为什么我写了个Shader挂上去却没反应为什么效果顺序调不了这些问题的根源都在于没理解URP后处理的执行时机和注入点。我打算按先搞懂框架再动手写最后处理顺序和性能这个逻辑往下走。中间会穿插我自己踩过的坑比如RenderTarget的格式选错导致精度丢失、Blit的缩放参数没设对导致画面偏移、还有那个经典的后处理在Scene视图生效但Game视图不生效的问题。这些细节文档里往往一笔带过但实际项目里能卡你半天。2. URP后处理的框架到底长什么样2.1 Volume框架与Renderer Feature的分工URP的后处理由两套机制共同完成理解它们的分工是动手的前提。Volume框架负责的是参数从哪来——它是一套基于体积Volume的参数管理系统你可以在场景里放多个Volume每个Volume里挂不同的Volume Component比如Bloom、Depth of Field、Color Adjustments。相机在渲染时会根据自身位置和Volume的混合距离把这些参数插值合并成一组最终值。这套机制的好处是参数可以随场景区域动态变化比如走进一个洞穴后处理参数自动切换成偏暗的色调。Renderer Feature负责的是效果怎么执行——它是一个可以插入到Renderer渲染流程中特定时机的执行单元。URP的Universal Renderer在渲染时会经过一系列Pass比如不透明物体Pass、透明物体Pass、后处理Pass。Renderer Feature允许你在这些Pass之间插入自己的渲染逻辑。URP内置的那些后处理效果Bloom、Tonemapping等本质上也是通过一个叫PostProcessPass的Renderer Feature来执行的只不过它被封装好了你看不到而已。这两者的关系可以这样理解Volume是菜谱告诉你要放多少盐多少糖Renderer Feature是厨师按照菜谱在正确的时机下锅。你写自定义后处理通常需要两者配合——如果效果需要可调参数就写一个Volume Component如果效果需要执行渲染就写一个Renderer Feature。当然如果你的效果参数是固定的也可以只写Renderer Feature把参数硬编码在里面。提示很多人第一次写自定义后处理时只写了Renderer Feature然后困惑为什么Inspector里调不了参数。原因就是缺少Volume Component这一层。参数暴露和渲染执行是两件事别混在一起想。2.2 后处理在URP渲染流程中的注入时机要搞清楚自定义后处理该插在哪里得先知道URP一帧的渲染大致经过哪些阶段。简化来说Universal Renderer的渲染流程是这样的先做深度预pass如果开了Depth Texture然后渲染不透明物体接着渲染透明物体之后进入后处理阶段最后输出到屏幕。后处理阶段内部又细分了好几个子步骤比如先做Bloom的预过滤再做Uber后处理把Color Grading、Tonemapping等合并到一个Pass里最后做Final Blit。Renderer Feature的注入点Injection Point决定了你的效果在哪个阶段执行。URP提供了几个标准的注入点Before Rendering Transparents在不透明物体渲染完之后、透明物体渲染之前。这个时机适合做那些需要在不透明物体上生效但不影响透明物体的效果比如某些基于深度的描边。Before Rendering Post Processing在透明物体渲染完之后、内置后处理之前。这是最常用的注入点你的自定义效果会先于Bloom、Tonemapping等内置效果执行。After Rendering Post Processing在内置后处理全部完成之后。适合做最终的画面调整比如自定义的胶片颗粒、屏幕空间噪点等。After Rendering Transparents在透明物体渲染之后立即执行此时内置后处理还没开始。选择哪个注入点取决于你的效果需要什么输入、以及它和内置后处理的先后关系。举个例子如果你写的是一个基于深度的雾效那它应该在不透明物体渲染完之后、透明物体渲染之前执行这样透明物体才能正确地与雾效混合。如果你写的是一个色彩分级效果那它应该在内置Tonemapping之后执行否则你的调整会被Tonemapping覆盖掉。这里有个容易忽略的点注入点决定了你的效果能拿到什么数据。比如在Before Rendering Post Processing这个点你拿到的是已经渲染完所有物体的Color Target但此时还没有经过任何后处理。如果你需要深度信息得确保URP的Depth Texture选项已经打开否则_CameraDepthTexture是空的。2.3 RenderTarget的分配与Blit的基本逻辑自定义后处理的核心操作就是拿一张图处理一下写回另一张图。这个操作在URP里通过Blit命令完成。但Blit不是随便调的涉及到RenderTarget的分配和释放处理不好会导致内存泄漏或者画面异常。一个标准的后处理Renderer Feature的Execute方法大致长这样先判断当前相机是否满足执行条件比如是不是Game相机、有没有开后处理然后从渲染上下文拿到当前的Color Target和Depth Target接着申请一张临时RenderTarget用你的材质把源图Blit到临时图再把临时图Blit回源图或者直接输出。最后别忘了释放临时RenderTarget。public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (renderingData.cameraData.cameraType ! CameraType.Game) return; CommandBuffer cmd CommandBufferPool.Get(CustomPostProcess); RenderTargetIdentifier source renderingData.cameraData.renderer.cameraColorTargetHandle; int tempRT Shader.PropertyToID(_TempPostProcessRT); cmd.GetTemporaryRT(tempRT, renderingData.cameraData.cameraTargetDescriptor); cmd.Blit(source, tempRT, m_Material); cmd.Blit(tempRT, source); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); cmd.ReleaseTemporaryRT(tempRT); }这段代码看起来简单但有几个细节值得说。第一cameraTargetDescriptor包含了当前相机的分辨率、格式、抗锯齿等信息用它来申请临时RT能保证和源图一致。第二GetTemporaryRT和ReleaseTemporaryRT必须成对出现否则会泄漏。第三如果你的效果不需要读写同一张图可以省掉第二次Blit直接把临时图作为最终输出但这样需要处理后续Pass的输入问题。注意在URP的新版本里cameraColorTarget这个API已经被标记为过时推荐用cameraColorTargetHandle。如果你用的是较新的URP版本记得跟进API变化否则编译会报warning甚至error。3. 手写一个自定义后处理效果的完整链路3.1 从Volume Component开始定义参数假设我们要做一个自定义色调分离效果把画面的颜色量化成有限的几个色阶类似海报化效果。第一步是定义参数。我们希望能控制色阶数量、以及效果的强度。那就写一个Volume Component[System.Serializable, VolumeComponentMenu(Custom/Posterize)] public class PosterizeVolume : VolumeComponent, IPostProcessComponent { public ClampedIntParameter levelCount new ClampedIntParameter(8, 2, 32); public ClampedFloatParameter intensity new ClampedFloatParameter(1f, 0f, 1f); public bool IsActive() intensity.value 0f; }这里有几个关键点。VolumeComponentMenu特性决定了这个组件在Volume的Add Override菜单里显示在哪个分类下。ClampedIntParameter和ClampedFloatParameter是URP提供的参数类型自带范围限制和插值功能。IPostProcessComponent接口的IsActive方法告诉URP这个效果当前是否应该执行——如果强度为0就直接跳过省性能。参数定义好之后在场景里放一个Volume添加这个Override你就能在Inspector里看到色阶数量和强度的滑条了。但此时还不会有任何视觉效果因为渲染逻辑还没写。3.2 Renderer Feature的骨架与材质准备接下来写Renderer Feature。它需要持有我们的材质并在合适的时机执行Blit。骨架如下public class PosterizeFeature : ScriptableRendererFeature { class PosterizePass : ScriptableRenderPass { private Material m_Material; private PosterizeVolume m_Volume; public PosterizePass(Material material) { m_Material material; renderPassEvent RenderPassEvent.BeforeRenderingPostProcessing; } public void Setup(PosterizeVolume volume) { m_Volume volume; } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (m_Volume null || !m_Volume.IsActive()) return; if (renderingData.cameraData.cameraType ! CameraType.Game) return; m_Material.SetInt(_LevelCount, m_Volume.levelCount.value); m_Material.SetFloat(_Intensity, m_Volume.intensity.value); CommandBuffer cmd CommandBufferPool.Get(PosterizePass); RenderTargetIdentifier source renderingData.cameraData.renderer.cameraColorTargetHandle; int temp Shader.PropertyToID(_PosterizeTemp); cmd.GetTemporaryRT(temp, renderingData.cameraData.cameraTargetDescriptor); cmd.Blit(source, temp, m_Material); cmd.Blit(temp, source); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); cmd.ReleaseTemporaryRT(temp); } } public Material m_Material; private PosterizePass m_Pass; public override void Create() { m_Pass new PosterizePass(m_Material); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { var volume VolumeManager.instance.stack.GetComponentPosterizeVolume(); m_Pass.Setup(volume); renderer.EnqueuePass(m_Pass); } }这段代码里AddRenderPasses是每帧调用的它从VolumeManager里取出当前生效的PosterizeVolume传给Pass。Execute里做实际的Blit。注意renderPassEvent设成了BeforeRenderingPostProcessing意味着我们的效果会在内置后处理之前执行。材质需要在Create里创建或者从外部赋值。通常的做法是在Feature的Inspector里暴露一个Shader引用运行时创建材质。这样美术可以直接在Inspector里换Shader不用改代码。3.3 Shader端的采样与色阶量化实现Shader部分我们写一个简单的全屏后处理Shader。核心逻辑是把颜色按色阶数量量化Shader Custom/Posterize { Properties { _MainTex (Texture, 2D) white {} } SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline } ZTest Always ZWrite Off Cull Off Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); int _LevelCount; float _Intensity; Varyings vert(Attributes input) { Varyings output; output.positionCS TransformObjectToHClip(input.positionOS.xyz); output.uv input.uv; return output; } half4 frag(Varyings input) : SV_Target { half4 color SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv); half3 posterized floor(color.rgb * _LevelCount) / _LevelCount; color.rgb lerp(color.rgb, posterized, _Intensity); return color; } ENDHLSL } } }这个Shader的关键在于floor(color.rgb * _LevelCount) / _LevelCount这一行。它把0到1的颜色值乘以色阶数取整再除回去就得到了量化后的颜色。_Intensity用来在原始颜色和量化颜色之间插值方便做效果过渡。提示全屏后处理Shader的顶点函数通常用TransformObjectToHClip把全屏三角形的顶点转到裁剪空间。URP的Blit会自动提供一个全屏三角形或四边形的顶点数据你不需要自己构造网格。3.4 把Feature挂到Renderer上并验证代码写完之后需要在URP的Renderer Asset上添加这个Feature。打开你的Universal Renderer Data资产在Inspector底部找到Renderer Features列表点Add Renderer Feature选择PosterizeFeature。然后把写好的Shader拖到Feature的Material字段上如果Feature设计成运行时创建材质这一步可能不需要。验证的时候先在场景里放一个Global Volume添加Posterize Override把强度拉到1色阶数调到4。如果一切正常Game视图的画面应该变成明显的色块风格。如果没反应按这个顺序排查Feature有没有加到Renderer上、Volume的Layer有没有被相机看到、相机的Post Processing选项有没有勾上、Shader有没有编译错误。我遇到过最常见的问题是Volume的Layer不对。Volume有一个Layer属性相机的Volume Mask必须包含这个LayerVolume才会生效。默认情况下相机的Volume Mask是Everything但如果你手动改过就可能漏掉。4. 效果顺序、叠加与性能的那些坑4.1 多个自定义效果之间的执行顺序控制当你写了多个自定义后处理效果它们的执行顺序由renderPassEvent决定。但问题是如果两个效果的renderPassEvent设成了同一个值它们的执行顺序就不确定了——URP内部可能按添加顺序执行也可能不按这取决于版本和实现细节。我实测下来同一个注入点下的多个Pass执行顺序并不总是稳定的。解决办法是给每个效果分配不同的renderPassEvent值。RenderPassEvent是一个枚举但它的值本质上是整数你可以用加法来微调。比如renderPassEvent RenderPassEvent.BeforeRenderingPostProcessing; // 另一个效果 renderPassEvent RenderPassEvent.BeforeRenderingPostProcessing 1;这样就能保证第一个效果先执行。但要注意RenderPassEvent的枚举值之间间隔是50或100加1不会和下一个标准注入点冲突。这种写法虽然有点hack但在需要精确控制顺序时很实用。另一个思路是把多个效果合并到一个Pass里用一个Shader做多次采样和处理。这样顺序完全由Shader代码决定不依赖Renderer Feature的排序。缺点是灵活性差改一个效果要动整个Shader。适合那些总是成组出现的效果比如色调分离边缘检测这种固定组合。4.2 与内置后处理的顺序冲突及解决自定义效果和内置后处理的顺序冲突是另一个高频问题。内置后处理Bloom、Tonemapping、Color Grading等都在PostProcessPass里执行这个Pass的注入点通常是AfterRenderingTransparents到AfterRenderingPostProcessing之间。如果你的自定义效果注入点选得不对就可能出现你的效果被Tonemapping覆盖或者你的效果在Bloom之前执行导致Bloom把色块又模糊了这类问题。判断顺序是否正确的原则很简单想清楚你的效果是在线性空间还是显示空间操作。Tonemapping的作用是把HDR颜色映射到LDR显示范围它是一个分界线。在Tonemapping之前颜色是线性的、可能超过1的在Tonemapping之后颜色是显示就绪的、0到1的。如果你的效果是色彩调整类的通常应该放在Tonemapping之后否则你调的颜色会被Tonemapping改变。如果你的效果是Bloom类的那必须放在Tonemapping之前因为Bloom需要HDR输入。具体到注入点选择效果类型推荐注入点原因基于深度的雾效、描边BeforeRenderingPostProcessing需要深度信息且应在Tonemapping前Bloom、GlowBeforeRenderingPostProcessing需要HDR输入色彩分级、LUTAfterRenderingPostProcessing应在Tonemapping后避免被映射改变胶片颗粒、噪点AfterRenderingPostProcessing最终画面叠加不应被后续处理影响自定义抗锯齿BeforeRenderingPostProcessing应在其他后处理前清理边缘注意如果你在AfterRenderingPostProcessing注入点做效果拿到的是已经Tonemapping过的LDR图像。此时做亮度相关的操作要小心因为高光已经被压缩了再拉亮度会显得很平。4.3 临时RenderTarget的格式选择与精度问题RenderTarget的格式选择是个容易被忽视但影响很大的点。默认情况下cameraTargetDescriptor的格式是GraphicsFormat.R8G8B8A8_UNorm也就是每通道8位。对于大多数后处理效果这个精度够用。但如果你做的效果涉及HDR颜色运算比如自定义的Bloom或者高动态范围的颜色调整8位精度就不够了——颜色值超过1的部分会被截断导致高光细节丢失。解决办法是在申请临时RT时指定更高的格式RenderTextureDescriptor desc renderingData.cameraData.cameraTargetDescriptor; desc.graphicsFormat GraphicsFormat.R16G16B16A16_SFloat; cmd.GetTemporaryRT(temp, desc);R16G16B16A16_SFloat是半精度浮点格式每通道16位能表示超过1的颜色值。代价是内存占用翻倍带宽消耗也增加。所以只在确实需要HDR精度时用别无脑全用浮点格式。另一个精度相关的坑是如果你在Blit时没有正确处理sRGB和线性空间的转换颜色会偏。URP的Blit默认会做sRGB转换但如果你手动指定了RenderTarget格式可能需要自己处理。判断方法很简单如果画面整体偏亮或偏暗但颜色关系是对的那多半是色彩空间转换的问题。4.4 移动端上的性能取舍与降级策略自定义后处理在移动端上的性能开销不容忽视。每一次Blit都意味着一次全屏的纹理读写在手机上这是实打实的带宽消耗。一个1080p的RT每帧读写一次就是约16MB的带宽如果叠加三四个效果带宽很快就上去了发热和掉帧随之而来。几个实用的优化策略。第一合并Pass。如果两个效果可以在一个Shader里完成就别分两次Blit。比如色调分离和亮度对比度调整完全可以在一个Shader里先量化再调对比度省一次全屏读写。第二降低分辨率。对于Bloom这类效果可以在半分辨率甚至四分之一分辨率上做最后再上采样合并。URP内置的Bloom就是这么干的。第三按需执行。利用Volume Component的IsActive方法当效果强度为0时直接跳过整个Pass不产生任何开销。第四移动端用更简单的算法。比如色阶量化移动端可以用查表法代替实时计算虽然灵活性差但快得多。我在一个移动端项目里做过测试一个全屏的色阶量化效果在骁龙8系芯片上1080p分辨率下单次Blit大约增加0.3ms的GPU时间。听起来不多但如果你叠了五个效果就是1.5ms再加上内置的Bloom和Tonemapping后处理总开销可能超过3ms。对于追求60帧的项目这个数字已经需要认真对待了。5. 调试与验证让效果可控可查5.1 Scene视图与Game视图表现不一致的排查Scene视图能看到效果Game视图没有或者反过来这个问题几乎每个做URP后处理的人都遇到过。根本原因通常是相机类型判断或者Volume Layer的问题。我在Pass的Execute里通常会加一行判断if (renderingData.cameraData.cameraType ! CameraType.Game) return;这行代码让效果只在Game相机上执行Scene视图不执行。如果你在调试时想在Scene视图里也看到效果把这行去掉或者改成同时允许Scene和Game。但要注意Scene视图的相机参数和Game相机不同效果表现可能有差异。另一个原因是Volume的Layer。Scene视图里的相机和Game相机的Volume Mask可能不一样。检查方法是选中相机看Inspector里的Volume Mask设置确保它包含了Volume所在的Layer。还有一个隐蔽的情况如果Volume的Mode设成了Local它只对进入其混合距离的相机生效。Scene视图的相机位置可能不在范围内所以看不到效果。5.2 用Frame Debugger定位后处理执行链路Unity的Frame Debugger是排查后处理问题的利器。打开Frame Debugger录制一帧你能看到所有的渲染事件按顺序排列。找到你的自定义Pass检查它是否在预期的位置执行、输入输出RT是否正确、Draw Call的参数是否符合预期。我常用的排查流程是这样的先在Frame Debugger里确认Pass有没有被执行。如果没有说明AddRenderPasses里没被Enqueue或者IsActive返回了false。如果执行了但画面没变化检查Blit的源和目标是不是同一张图——如果源和目标相同Blit可能被优化掉或者产生未定义行为。如果画面有变化但不对检查Shader里的采样坐标和参数传递。Frame Debugger里还能看到每个Pass的RT格式和分辨率。如果你发现临时RT的格式和预期不符回去检查GetTemporaryRT的参数。这个工具能省掉大量靠猜来排查的时间。5.3 参数暴露与美术友好度的平衡技术实现跑通之后还有一个容易被忽视的问题参数怎么暴露给美术。很多开发者习惯把所有参数都塞进Volume Component结果美术面对几十个滑条一脸懵。我的经验是暴露给美术的参数不超过5个而且每个都要有明确的语义。具体做法是分层暴露。底层参数比如采样偏移、迭代次数用[HideInInspector]隐藏或者干脆不暴露在代码里根据质量等级自动设置。中层参数比如效果强度、范围暴露但给合理的默认值和范围限制。顶层参数比如效果开关、预设选择用枚举或者Toggle让美术一键切换。另外给参数加Tooltip特性说明用途比什么文档都管用[Tooltip(控制色阶数量值越小色块越明显)] public ClampedIntParameter levelCount new ClampedIntParameter(8, 2, 32);美术在Inspector里鼠标悬停就能看到说明省去了大量沟通成本。这个细节虽小但在团队协作里能明显提升效率。6. 从能跑到好用一些个人经验自定义后处理这件事从能跑到好用之间隔着不少细节。我自己的体会是前期多花时间把框架搭对后期改效果就是改Shader和参数的事不用动流程代码。反过来如果一开始把逻辑写死在Pass里后面每加一个效果都要改结构越改越乱。几个我踩过之后觉得值得记住的点。第一永远给临时RT用GetTemporaryRT而不是new RenderTexture前者由URP的RT池管理能复用内存后者每次创建销毁GC压力大。第二Blit的缩放参数要显式设置默认的Blit(src, dst, mat)在某些分辨率下会有半像素偏移用Blit(src, dst, mat, pass)或者手动设置cmd.SetGlobalVector(_BlitScaleBias, ...)能避免。第三在移动端上后处理Shader尽量用half精度而不是floatGPU对half的吞吐量更高视觉差异在大多数情况下看不出来。还有一个关于调试的建议给每个自定义效果写一个独立的测试场景里面放几个标准测试物体一个带HDR自发光的球、一个带复杂纹理的平面、一个半透明物体这样调效果时能快速看出各种材质下的表现。比在正式场景里调效率高得多。最后说一个心态上的事。URP的后处理API在不同版本之间变化不小今天能用的写法明天可能就过时了。遇到API报错别慌先看版本号再去翻对应版本的文档和源码。URP的源码是开放的PostProcessPass.cs和ScriptableRenderer.cs这两个文件值得反复读很多文档里没写清楚的执行顺序和时机源码里一目了然。
阅读完成 · 觉得有帮助?
咨询建站