2026/10/4 7:48:02

URP自定义后处理单Pass渲染:深度描边与Renderer Feature实战

URP自定义后处理单Pass渲染:深度描边与Renderer Feature实战 正巧最近在帮一个二次元风格项目调画面官方URP后处理栈里翻了半天发现想要的描边和局部风格化效果根本没有现成节点。最后还是绕回老本行自己写URP自定义后处理。这篇文章我会围绕URP 12.x这条管线把自定义后处理里最常用的单pass渲染这套做法讲透——从Renderer Feature的注册流程到全屏三角形如何绘制再到Shader里怎么用深度纹理完成一个真正单Pass的描边效果最后把参数接进Volume系统方便美术同事调参。适合两类人看一类是技术美术想绕开官方Volume组件做风格化效果另一类是Unity开发想搞清楚ScriptableRendererFeature在底层到底怎么运作。读完你能直接复刻一个可运行的自定义后处理并能理解为什么要选单pass、为什么边缘检测要用深度而不是颜色。1. URP 12.x的后处理机制自己动手前的底层认知1.1 OnRenderImage在URP里为什么失效先聊一个很多老人容易踩的坑。Built-in渲染管线时代写后处理最顺手的就是挂一个MonoBehaviour在OnRenderImage里拿source纹理用Graphics.Blit(source, dest, material)把全屏效果过一遍。这个套路顺手是因为Built-in把“抓屏到RT”“恢复RT”这套脏活都替你做了代价是全局状态满天飞多级管线叠加时经常出现奇怪的RT残留。URP 12.x彻底改掉了这套。渲染流程变成数据驱动相机渲染不再是脚本主动调用而是被ScriptableRenderer拆成一连串RenderPass。相机最终输出也不是随便一个全局变量能拿到的必须通过renderingData.cameraData.renderer去取目标句柄。这意味着Old School的OnRenderImage方式在URP里根本不会被调用前期如果不做这一层认知切换后面写再多Shader都白搭。1.2 Renderer Feature和RenderPass的工作方式URP给开发者留的扩展入口是ScriptableRendererFeature。你可以把它理解成管线上的一道注册清单而真正的渲染逻辑在配套的ScriptableRenderPass里。Feature负责两件事一是Create()时创建资源二是AddRenderPasses()时决定要不要把某个Pass塞进当前相机的渲染队列。Pass则负责在Execute()里发射CommandBuffer告诉GPU画什么、怎么画。URP 12.x提供了不少RenderPassEvent插入点比如AfterRenderingOpaques、AfterRenderingTransparents、AfterRenderingPostProcessing。自定义后处理最常用的是AfterRenderingTransparents因为此时场景不透明和透明物体都画完了深度缓冲内容完整我们在后处理阶段拿到的_CameraDepthTexture是最全的。如果希望在官方Bloom、Tonemapping之后再叠加一层效果就改成AfterRenderingPostProcessing。这个选择直接决定效果的叠加顺序没有绝对对错只有场景需求。1.3 单pass渲染到底解决什么问题后处理效果按复杂度通常分成两类一类是多pass链式处理最常见的是高斯模糊先水平模糊一遍再垂直模糊一遍然后又混合回去另一类是单pass直接出结果适合逻辑集中在单个片元上的效果比如色彩量化、描边、色差偏移、局部亮度调整。单pass渲染的核心优势在于省带宽。GPU上最贵的操作往往不是计算而是纹理读写的带宽占用。每多一个Pass就等于把中间结果写进一张RT再读出来一次。移动端的带宽更是奢侈品帧率瓶颈很多时候不在ALU而在RT的分配和读写。单pass把所有采样和计算压缩在一个片元着色器里中间不落任何RT性能自然友善。这也匹配最近讨论度很高的Unity二次元Shader需求——卡通渲染里的描边、阴影分级、边缘光很多都适合放在后处理单pass里统一处理。2. 单Pass后处理的核心设计全屏三角形与数据流2.1 为什么不需要全屏Quad很多入门教程会教你创建一个覆盖屏幕的四边形Mesh然后DrawMesh。Quad方案这能用但性能上有个浪费点屏幕对角线方向两个三角形在斜线处有重叠覆盖硬件需要额外光栅化两次。真正讲究的做法是用全屏大三角形三个顶点直接覆盖整个裁剪空间一次绘制完成没有对角线浪费。在Shader里我们并不真的需要一个Mesh资产而是利用SV_VertexID在顶点着色器里算出三个顶点位置。Core RP提供GetFullScreenTriangleVertexPosition()和GetFullScreenTriangleTexCoord()配合cmd.DrawProcedural一次调用就能画出覆盖全屏的三角形。关键命令是cmd.DrawProcedural(Matrix4x4.identity, material, 0, MeshTopology.Triangles, 3, 1);这种绘制方式不绑定任何Mesh资源顶点数只有3个是单pass渲染效率最高的全屏方式。2.2 输入输出与深度纹理的配合写自定义后处理之前先要把数据流想清楚。URP 12.x里相机最终渲染目标可以通过cameraColorTargetHandle拿到。我们要做的通常是读取一些信息在片元着色器里计算再把结果写回相机目标。如果效果只依赖深度纹理那么输入输出天然不冲突——深度纹理是管线前期生成的和当前颜色目标没有读写依赖。这就是单pass渲染最舒服的场景。以深度描边为例我们只需要在片元着色器里采样_CameraDepthTexture做Sobel边缘检测然后把边缘颜色写进颜色目标非边缘区域直接discard。整个过程不需要额外分配中间RT一次全屏三角形绘制一个Shader Pass结束。2.3 什么时候必须引入临时RT最好先泼盆冷水不是所有单pass后处理都能完全脱离临时RT。如果你的效果必须读当前相机颜色本身作为输入比如做一个颜色偏移或径向模糊那么把颜色目标同时当输入和输出会产生数据依赖GPU会出现隐式同步甚至未定义行为。这种情况就必须先把当前颜色拷贝到一张临时RTShader里以临时RT为输入把结果写回相机目标。拷贝这一步在URP里通常用CommandBuffer.Blit实现。严格说起来它多了一次绘制但很多人依然习惯把它称为单pass后处理因为核心效果的Shader只写了一个Pass临时拷贝只是数据搬运。在实际工程里你要分清自己写的是“纯单Pass”还是“单Shader Pass一次拷贝”这关系到性能预估和效果边界。3. 手写一个基于深度描边的单Pass后处理3.1 框架代码创建Feature和Pass我直接给出一个能跑的方案。首先是自定义Volume组件基础版后面我会单独讲参数接进Volume的细节这里先让它可以被Feature读取。using System; using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; [Serializable, VolumeComponentMenu(Post-processing/Custom/Edge Detection)] public class EdgeDetectionVolume : VolumeComponent, IPostProcessComponent { public ClampedFloatParameter edgeWidth new ClampedFloatParameter(1f, 0.2f, 8f); public ClampedFloatParameter depthThreshold new ClampedFloatParameter(0.1f, 0.02f, 0.5f); public ColorParameter edgeColor new ColorParameter(Color.black); public BoolParameter isEnabled new BoolParameter(false); public bool IsActive() { return isEnabled.value edgeColor.value.a 0; } public bool IsTileCompatible() { return true; } }然后是Feature。核心职责是创建材质、创建Pass实例并在需要时入队public class EdgeDetectionFeature : ScriptableRendererFeature { public Shader edgeShader; public RenderPassEvent passEvent RenderPassEvent.AfterRenderingTransparents; private EdgeDetectionPass edgePass; private Material material; public override void Create() { if (edgeShader ! null) { material CoreUtils.CreateEngineMaterial(edgeShader); } edgePass new EdgeDetectionPass(material, passEvent); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { var volume VolumeManager.instance.stack.GetComponentEdgeDetectionVolume(); if (volume null || !volume.IsActive()) return; edgePass.Setup(volume); renderer.EnqueuePass(edgePass); } protected override void Dispose(bool disposing) { base.Dispose(disposing); CoreUtils.Destroy(material); } }Pass内部写得干净一点。构造函数里通过ConfigureInput告诉URP需要深度纹理让管线提前准备_CameraDepthTexture。OnCameraSetup里把相机颜色目标指定为本Pass的输出目标。Execute里就是设置全局参数然后画全屏三角形。public class EdgeDetectionPass : ScriptableRenderPass { private readonly Material material; private readonly ProfilingSampler profilingSampler; private EdgeDetectionVolume volume; public EdgeDetectionPass(Material material, RenderPassEvent passEvent) { this.material material; renderPassEvent passEvent; profilingSampler new ProfilingSampler(EdgeDetectionPass); ConfigureInput(ScriptableRenderPassInput.Depth); } public void Setup(EdgeDetectionVolume volume) { this.volume volume; } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { var cameraTarget renderingData.cameraData.renderer.cameraColorTargetHandle; ConfigureTarget(cameraTarget); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (material null || volume null) return; var cmd CommandBufferPool.Get(EdgeDetectionPass); using (new ProfilingScope(cmd, profilingSampler)) { cmd.SetGlobalFloat(_EdgeWidth, volume.edgeWidth.value); cmd.SetGlobalFloat(_DepthThreshold, volume.depthThreshold.value); cmd.SetGlobalColor(_EdgeColor, volume.edgeColor.value); cmd.DrawProcedural(Matrix4x4.identity, material, 0, MeshTopology.Triangles, 3, 1); } context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } }这里有一个容易被忽略的细节volume引用是在AddRenderPasses时传进去的但真正执行Execute时可能已经间隔了一帧。由于Volume本身在VolumeManager中属于全局状态每次Execute都读取volume.xxx.value所以参数依然是实时的这也是后面接入Volume系统最大的价值。3.2 Shader核心逻辑与Sobel边缘检测Shader端最重要的事情有两件顶点阶段用全屏三角形的标准姿势片元阶段做Sobel深度边缘检测。完整代码如下Shader Custom/PostProcess/EdgeDetection { Properties { _EdgeColor(Edge Color, Color) (0, 0, 0, 1) _EdgeWidth(Edge Width, Range(0, 8)) 1 _DepthThreshold(Depth Threshold, Range(0, 0.5)) 0.1 } SubShader { Tags { RenderPipeline UniversalPipeline } ZWrite Off ZTest Always Cull Off Blend SrcAlpha OneMinusSrcAlpha Pass { HLSLPROGRAM #pragma vertex Vert #pragma fragment Frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl CBUFFER_START(UnityPerMaterial) float4 _EdgeColor; float _EdgeWidth; float _DepthThreshold; CBUFFER_END struct Attributes { uint vertexID : SV_VertexID; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; Varyings Vert(Attributes input) { Varyings output; output.positionCS GetFullScreenTriangleVertexPosition(input.vertexID); output.uv GetFullScreenTriangleTexCoord(input.vertexID); return output; } half4 Frag(Varyings input) : SV_Target { float2 uv input.uv; float2 texelSize _EdgeWidth * _ScreenParams.zw; float depthCenter LinearEyeDepth(SampleSceneDepth(uv), _ZBufferParams); float depthLeft LinearEyeDepth(SampleSceneDepth(uv - float2(1, 0) * texelSize), _ZBufferParams); float depthRight LinearEyeDepth(SampleSceneDepth(uv float2(1, 0) * texelSize), _ZBufferParams); float depthUp LinearEyeDepth(SampleSceneDepth(uv float2(0, 1) * texelSize), _ZBufferParams); float depthDown LinearEyeDepth(SampleSceneDepth(uv - float2(0, 1) * texelSize), _ZBufferParams); float dx abs(depthLeft - depthRight); float dy abs(depthUp - depthDown); float edge max(dx, dy); if (edge _DepthThreshold) { discard; } return half4(_EdgeColor.rgb, _EdgeColor.a); } ENDHLSL } } }这里的Sobel算子我做了简化处理只用十字四方向的深度差来计算梯度。实际项目里如果边缘不够匀称可以扩成3x3带权重的Sobel效果更稳但采样次数从4次变成8次需要自己权衡。注意_ScreenParams.zw是屏幕分辨率的倒数_EdgeWidth按像素控制采样距离这样不会因为分辨率不同导致描边粗细腰斩。还有一个小心思非边缘区域用discard直接丢弃连颜色目标都不写既保留了场景原始颜色又规避了颜色纹理反馈读取从而保持了这个效果真正意义上的单Pass单Draw。边缘区域则利用混合模式实现半透明描边比如二次元里那种带柔边的黑色描边直接把_EdgeColor.a调成0.7左右就够了。3.3 调整渲染时机和混合模式如果描边效果和官方后处理存在冲突优先检查RenderPassEvent。比如你把描边放在AfterRenderingPostProcessing这时画面很可能已经经过了Bloom和Tonemapping深度纹理仍然可用但边缘线的表现会受到颜色空间和色调映射影响。放在AfterRenderingTransparents则更贴近场景原始颜色适合做画面主体与背景分离的描边。混合模式这里单独提一句因为很多新手会把Blend Off写成默认导致边缘以外出现黑块。原因很简单如果片元没有discard而是返回了黑色颜色目标就被覆盖了。我用discard的方式就是为了让非边缘像素不参与写入这种情况下即使Blend Off也不会出黑块但为了支持半透明描边我保留了SrcAlpha OneMinusSrcAlpha两者结合最稳妥。4. 把参数接进Volume系统4.1 为什么用VolumeComponent而不是普通MonoBehaviour早期写后处理很多人都习惯在Inspector上直接拖一个材质然后美术去材质面板调参数。这方案在单场景单相机下没问题但一旦涉及不同关卡、不同后处理强度、运行时动画干预材质里的参数就变成一团乱麻。URP的Volume系统就是为这套动态调节设计的。Volume能把后处理参数从“某个材质实例”里解放出来变成全局可覆盖的状态。美术在场景里放置Volume调整范围后就能局部影响后处理效果程序在运行时通过代码修改参数还能做后处理强度的渐入渐出。本质上Volume系统维护了一个按优先级排序的“参数栈”运行时会根据相机位置和混合权重算出当前实际参数。4.2 自定义Volume组件前面已经给出了EdgeDetectionVolume的完整定义。这里再解释几个实现要点。VolumeComponentMenu里的路径决定了它在Volume Override面板里出现的位置建议命名成Post-processing/Custom/Edge Detection方便和官方后处理放在一组。参数类型要选择VolumeParameter的派生类比如ClampedFloatParameter适合有范围的浮点值ColorParameter适合颜色。IPostProcessComponent这个接口提供了IsActive()和IsTileCompatible()两个方法。IsActive()用来告诉URP这个组件当前是否需要生效我在里面判断了isEnabled和颜色的alpha如果边缘颜色alpha为0说明美术想关闭效果Feature就不会入队。IsTileCompatible()返回true表示效果可以被Tile化处理一般后处理单pass都直接返回true。4.3 Pass执行时如何取值关键点是Feature和Pass分离后的参数流动。在AddRenderPasses里我们用VolumeManager.instance.stack.GetComponentEdgeDetectionVolume()拿到当前Volume栈解析后的最终参数组件然后传给Pass。由于这个组件在运行时会根据相机位置实时更新Shader每次执行的参数就是当前混合后的参数不需要每帧手动从材质上拷一遍。这种设计带来的实际好处是如果你的项目里用了Timeline做后处理动画只需要对EdgeDetectionVolume里的参数打关键帧剩下的工作都交给Volume系统。也不需要在Materials上维护多套参数实例美术不会再陷入“这个场景为什么描边颜色不对”的排查泥潭。5. 踩坑与调优记录5.1 后处理没生效、黑屏、边缘异常的排查顺序我先给一个排查表这些都是实际开发里遇到过的现象可能原因解决方式后处理完全没出现Feature没挂到URP Renderer选中URP Asset在Renderer List里添加Renderer Feature没有任何描边Volume中isEnabled为false确认场景有Volume且组件已激活深度纹理黑色没有ConfigureInput(ScriptableRenderPassInput.Depth)在Pass构造函数里补上这行屏幕整片黑Shader返回了全黑且没有discard检查Kernel中的边界逻辑避免非边缘像素写入边缘锯齿严重采样间距过大或阈值过小打开MSAA或使用smoothstep过渡编辑器和移动端效果不一致深度线性化或UV翻转差异统一用LinearEyeDepth和GetFullScreenTriangleTexCoord最深的一个坑是我第一次写时忘记了ConfigureInput导致Shader里SampleSceneDepth采样到的全是0边缘被识别成整屏幕几乎全屏都画上了黑边。这个问题在编辑器的Frame Debugger里能看到_CameraDepthTexture缺失警告但如果是移动端真机往往什么都不显示排查起来很费劲。5.2 移动端单pass后处理的性能教训在移动端做单pass后处理最大的性能杀手不是像素着色器里的算术而是纹理读取带宽。以这个单pass边缘检测为例每个片元至少5次深度纹理采样如果做3x3的Sobel就要9次。中端GPU的显存带宽有限深度纹理虽然精度高于颜色但采样次数过多依然会明显吃掉帧时间。我实测过一个项目同样在1080p分辨率下4次采样的十字形算子比9次采样的Sobel快了将近40%。视觉上十字形在45度斜面上的边缘会略抖但对于大多数卡通描边来说完全够用。如果美术坚持要更好的边缘质量可以在边缘计算后加一步abs(dx) abs(dy)的形态学处理或者采样后对梯度做一次指数衰减视觉上会比单纯拉阈值更干净。还要注意不要在高DPI缩放或动态分辨率开启时忽略_ScreenParams的变化。URP平时用动态分辨率时全屏三角形的裁剪坐标不变但UV和纹素大小每一帧都可能变Shader里如果用固定的偏移量就会出现描边粗细忽大忽小。5.3 关于URP 12.x里几个容易误读的点URP 12.x在Unity 2021.3 LTS上属于比较成熟的分支但有几个习惯需要特别培养。第一是尽量不依赖GrabPassURP 12.x虽然还能兼容但GrabPass在移动端会产生非常大的带宽开销而且和SRP的RT管理方式格格不入。第二是不要在后处理Pass里每帧GetTemporaryRT如果效果确实需要临时RT应该用RTHandle预先分配并在Feature的Dispose里释放。第三是不要把材质参数在Execute里一次性全部SetGlobal能合并的尽量合并在一个CBUFFER里减少驱动状态切换。如果对Shader练习的敏感度还不够我的经验是可以刷一遍The Book of Shader的Fragment练习把UV坐标、像素间距、屏幕分辨率这些概念彻底吃透。很多后处理效果的思路就是从这些基础思想里延伸出来的比如边缘检测本质就是邻域差分和SDF绘制里判断边缘距离的思想一脉相承。Unity Shader和GLSL虽然语法不同但片元着色器的思维方式完全互通。5.4 给NPR卡通渲染方向的一点扩展这个单pass深度描边方案天然能和Unity二次元Shader结合。市面上很多卡通渲染项目里描边是在几何阶段做背面放大但那套方案对模型拓扑要求比较高遇到破碎网格就容易出现描边断裂。后处理深度描边在场景级画面中更稳定——不管模型布线多乱只要深度有跳变描边就存在。如果要把这套描边从“场景级”变成“角色专用级”一个简单办法是在SceneDepth之外再合并一张角色ID贴图在边缘计算时限制同一张ID内部不描边角色与场景交界处正常描边。这会多增加一次纹理采样但依然可以在单Pass里完成。头部结合先前做的_depth threshold和_EdgeWidth整个内部美型度都会上一层。在我实际项目的经验里后处理单pass渲染最值得投入的部分往往是“对数据流的理解”而不是Shader语法本身。你把什么时候需要临时RT、什么时候可以纯靠深度绕开、什么时候能靠discard保底搞清楚再复杂的后处理都能拆成若干个清晰的单Pass。URP 12.x这套新管线确实改了不少旧习惯但一旦摸透了Renderer Feature和RenderPass这套骨架写自定义后处理反而比Built-in时代更顺手。