如果你是从内置渲染管线转过来写 URP 的同学,第一次想给屏幕加特效,第一反应大概率是找 OnRenderImage。然后你会发现整个 Unity API 里都搜不到这个函数,再然后就是翻文档、翻论坛、翻一堆半新不旧的代码,最后在 Renderer Feature 和 ScriptableRenderPass 之间来回折腾。别问我怎么知道的。URP 不是把后处理藏起来了,而是把原来"摄像机挂脚本"的模式整个换成了"渲染器插拔零件"的模式。这篇文章我会把自定义后处理这条链路完整拆开:Renderer Feature 是什么、Volume Component 怎么当调参面板、Shader 里怎么正确采样主纹理、Blit 到底怎么用,然后手把手实现一个"灰度保留色"的效果。不管你最后要做风格化滤镜、故障效果,还是移动端性能优化,看明白这一套之后都能直接改。
1. 先看懂 URP 后处理的运行链路
想改代码之前,得先理解 URP 到底把后处理安排在哪一步。这部分不需要代码,但没搞懂它,后面写出再多的 Pass 也只是在瞎试。
1.1 内置管线的老传统为什么在 URP 里失效
内置管线的"屏幕特效"本质是一个 MonoBehaviour。相机渲染完画面之后,引擎调用 OnRenderImage,把渲染结果作为一张纹理传进来,你用 Graphics.Blit 拿这张纹理经过某个 Material 再画一遍,就把结果贴到屏幕。这个设计的好处是简单,坏处是没法控制渲染顺序。脚本加载顺序、多个特效之间的先后、不同平台 RT 的差异,全都靠经验去怼。
URP 是 Scriptable Render Pipeline,渲染流程是一段由 ScriptableRenderContext 编排的代码,OnRenderImage 根本不存在于新的渲染流程里。当相机渲染到某个 renderPassEvent 节点时,URP 会去收集当前 Renderer 上挂的所有 Feature,按顺序把它们的 Pass 排进队列,再由上下文统一执行。所以我们的自定义后处理,说穿了就是两件事:第一,在正确的时机会把屏幕当前渲染结果当成输入纹理;第二,用一段材质把这纹理加工一下,再放回屏幕目标。这个过程叫 Blit。
1.2 渲染事件位置与 Blit 机制
renderPassEvent 可以理解成流水线上的一个工位。URP 内置了从 BeforeRendering 到 AfterRendering 的一堆工位。自定义后处理一般选 BeforeRenderingPostProcessing,这个工位在 Bloom、色调映射等内置后处理之前。选它意味着你的特效吃的是场景原图,输出还能被内置后处理继续加工。如果你希望特效在最终色调映射之后再加一层,比如屏幕边框、扫描线这种叠加,就选 AfterRenderingPostProcessing。
| 事件值 | 时机 | 常见用途 |
|---|---|---|
| BeforeRendering | 渲染一切之前 | 提前画遮罩 |
| BeforeRenderingPrePasses | 深度/法线预处理前 | 自定义深度生成 |
| BeforeRenderingOpaques | 不透明物体前 | 背景绘制 |
| AfterRenderingOpaques | 不透明物体后 | 深度雾、边缘光 |
| BeforeRenderingPostProcessing | 内置后处理前 | 自定义后处理,推荐 |
| AfterRenderingPostProcessing | 内置后处理后 | 画面叠加、扫描线 |
| AfterRendering | 全部完成 | 全屏覆盖层 |
Blit 也不玄乎,本质就是一次带材质的全屏 DrawCall。你可以把它想象成 Photoshop 里的一个滤镜图层:源图、目标图、滤镜脚本,三者缺一不可。在 URP 里,我们经常先申请一张临时 RT,第一次用 Material 把相机目标的内容处理到临时 RT,第二次再把临时 RT 拷回相机目标。这样画面上看到的就是处理后的结果。
2. 自定义后处理的"三件套"和一份 Shader
动手写之前,先把你之后要碰到的类都认识一遍。理解它们各自负责什么,比背诵 API 重要得多。
2.1 ScriptableRendererFeature 和 ScriptableRenderPass 的分工
Feature 是一个 ScriptableObject,挂在 Forward Renderer 资产上,有点像电源插座。Pass 是真正干活的 ScriptableRenderPass,相当于插在插座上的电器。Feature 负责定义 Pass 的生命周期和配置,把 Pass 塞进渲染器队列;Pass 负责在渲染时申请 RT、执行 Blit、释放 RT。
很多教程喜欢把 Feature 和 Pass 写在一起,其实分开理解更清楚:Feature 里通常只有 Create 和 AddRenderPasses 两个方法,Create 在渲染器加载时调用一次,AddRenderPasses 在每帧渲染前被调用,用来把 Pass 加入队列。Pass 里的 Execute 才是每帧真正执行渲染命令的地方。
2.2 Volume Component:后处理参数的遥控器
URP 内置的 Bloom、Vignette 都是挂在 Volume 上的,用 Volume Profile 管理。自定义后处理想跟美术协作,最好也走这套机制,而不是把参数直接写死在 Renderer Feature 上。原因很简单:Volume 支持全局体积、触发区域、多场景不同强度,美术可以在不碰代码的情况下调整效果。
你在 Renderer Feature 的 Execute 里通过VolumeManager.instance.stack.GetComponent<你的组件>()拿到当前生效参数。这个组件继承自 VolumeComponent,并且要实现 IPostProcessComponent 接口。后面我会给完整代码。
2.3 Shader 里到底在干什么
后处理 Shader 和普通材质不一样。它不负责渲染物体,而是画一整屏的矩形或三角形。Unity 会把上一帧画面暴露给 Shader 作为_MainTex,你在 fragment shader 里逐像素采样这张纹理并输出新的颜色。所以这个 Shader 一般不需要模型变换、不需要光照计算,只需要一个全屏坐标和 UV。它基本也不做深度测试和深度写入,ZWrite Off、ZTest Always是标配。
如果你经常在 URP 的中国社区看帖,会发现有人问"后处理 Shader 为什么不能用 SRP Batcher",其实全屏后处理的 draw call 数量本来就很低,重点在 Fragment 阶段的性能,而不是合批。
3. 实操:做一个"灰度保留色"的全屏后处理
这个效果的玩法很简单:全屏变成黑白,但物体原来接近某个颜色时保留彩色。很多动作片预告片里的"血迹是红的,周围全是灰的"就是它。选它作为案例,是因为逻辑足够短,但覆盖了参数、Volume、采样、距离计算这些完整知识点。
3.1 准备工程与渲染器
新建 URP 项目,或者在现有项目里把渲染管线切换到 URP。找到 Forward Renderer 资产(通常在 Project 面板里,是 *.asset 文件),在 Inspector 底部点 Add Renderer Feature,选择 KeepColorFeature。摄像机不需要挂任何脚本,只要 Camera 组件上的 Post-processing 保持开启(URP 默认开)。
还有一点值得提前说:如果你用的是比较新的 URP 版本,它自带了一个 Full Screen Pass Renderer Feature,可以直接选择材质做后处理。但这份教程仍然建议你手写一遍 Feature/Pass,因为只有手写你才会真正理解它运行在哪一步、怎么申请 RT、怎么获取 Volume 参数、怎么排查问题。内置组件可以帮你快速出效果,但帮不了你理解原理。
3.2 先写 Volume 参数类
Volume 参数类负责把美术能调的参数暴露出来。这里我放四个参数:整体强度、目标保留色、保留范围、过渡柔和度。
using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; [System.Serializable, VolumeComponentMenu("Custom Post/Keep Color")] public sealed class KeepColorEffect : VolumeComponent, IPostProcessComponent { public ClampedFloatParameter intensity = new ClampedFloatParameter(1f, 0f, 1f); public ColorParameter targetColor = new ColorParameter(new Color(0.8f, 0f, 0f)); public ClampedFloatParameter keepRange = new ClampedFloatParameter(0.25f, 0f, 1f); public ClampedFloatParameter keepSmooth = new ClampedFloatParameter(0.1f, 0f, 1f); public bool IsActive() => intensity.value > 0f; public bool IsTileCompatible() => false; }VolumeComponentMenu里的路径决定了在 Volume Profile 的 Add Override 菜单里显示在哪一层。ClampedFloatParameter的构造参数依次是默认值、最小值、最大值。IsActive()用于告诉 URP 当前效果是否生效,如果强度为 0,整个 Pass 应该直接跳过,省掉一次全屏 DrawCall。
3.3 写 Renderer Feature 和 Render Pass
Renderer Feature 类负责创建 Pass 并把 Pass 排进队列。代码里有一个 Settings 类型,用来在 Inspector 上暴露材质和渲染时机。
using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class KeepColorFeature : ScriptableRendererFeature { [System.Serializable] public class Settings { public Material material; public RenderPassEvent renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing; } public Settings settings = new Settings(); private KeepColorPass _pass; public override void Create() { _pass = new KeepColorPass(settings.material, settings.renderPassEvent); } public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData) { if (settings.material == null) return; _pass.UpdateMaterial(settings.material); _pass.renderPassEvent = settings.renderPassEvent; renderer.EnqueuePass(_pass); } }Pass 才是核心。它做的事情可以拆成三步:在相机设置阶段申请一张临时 RT;在执行阶段用材质把源画面 Blit 到临时 RT,再临时 RT Blit 回相机目标;在清理阶段释放临时 RT。
using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; public class KeepColorPass : ScriptableRenderPass { private const string PassTag = "KeepColorPass"; private static readonly int TempRT = Shader.PropertyToID("_KeepColorTempRT"); private Material _material; private RenderTargetIdentifier _tempTarget; public KeepColorPass(Material material, RenderPassEvent renderPassEvent) { _material = material; this.renderPassEvent = renderPassEvent; } public void UpdateMaterial(Material material) { _material = material; } public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData) { RenderTextureDescriptor desc = renderingData.cameraData.cameraTargetDescriptor; desc.msaaSamples = 1; desc.depthBufferBits = 0; cmd.GetTemporaryRT(TempRT, desc, FilterMode.Bilinear); _tempTarget = new RenderTargetIdentifier(TempRT); } public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData) { if (_material == null) return; var effect = VolumeManager.instance.stack.GetComponent<KeepColorEffect>(); if (effect == null || !effect.IsActive()) return; _material.SetFloat("_Intensity", effect.intensity.value); _material.SetColor("_TargetColor", effect.targetColor.value); _material.SetFloat("_KeepRange", effect.keepRange.value); _material.SetFloat("_KeepSmooth", effect.keepSmooth.value); CommandBuffer cmd = CommandBufferPool.Get(PassTag); RenderTargetIdentifier source = renderingData.cameraData.renderer.cameraColorTargetHandle; cmd.Blit(source, _tempTarget, _material, 0); cmd.Blit(_tempTarget, source); context.ExecuteCommandBuffer(cmd); CommandBufferPool.Release(cmd); } public override void OnCameraCleanup(CommandBuffer cmd) { cmd.ReleaseTemporaryRT(TempRT); } }GetTemporaryRT从 RT 池里取一块屏幕尺寸的临时纹理,这不是每帧重新分配显存,而是有池化的,不用担心反复申请导致 GC。desc.msaaSamples = 1的意思是临时 RT 不参与 MSAA,因为源画面已经是一张解析后的相机目标,没必要再申请带 MSAA 的 RT 做中间结果。desc.depthBufferBits = 0是因为后处理一般用不到深度。
3.4 写屏幕特效 Shader
Shader 这边只需要一个 Pass。Fragment 里的逻辑是:先采样屏幕原始颜色,计算亮度得到灰度值,然后计算像素颜色和目标保留色的距离,距离越近越倾向于保留原色,距离越远越倾向于灰度。
Shader "CustomPost/KeepColor" { SubShader { Tags { "RenderPipeline" = "UniversalPipeline" } Pass { Name "KeepColorPass" 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 { float4 positionOS : POSITION; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float2 uv : TEXCOORD0; }; TEXTURE2D(_MainTex); SAMPLER(sampler_MainTex); float _Intensity; half4 _TargetColor; float _KeepRange; float _KeepSmooth; Varyings Vert(Attributes input) { Varyings output; output.positionCS = TransformObjectToHClip(input.positionOS.xyz); 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(_MainTex, sampler_MainTex, input.uv); half luma = dot(color.rgb, half3(0.299, 0.587, 0.114)); half3 gray = luma.xxx; float dist = distance(color.rgb, _TargetColor.rgb); float keepMask = 1.0 - smoothstep( _KeepRange - _KeepSmooth * 0.5, _KeepRange + _KeepSmooth * 0.5, dist); half3 effectColor = lerp(gray, color.rgb, keepMask); effectColor = lerp(color.rgb, effectColor, _Intensity); return half4(effectColor, color.a); } ENDHLSL } } }亮度系数的选取不是随便定的。人眼对绿色最敏感,所以 0.587 给绿色,红色 0.299,蓝色 0.114,这才是标准的 Rec.709 亮度公式。distance算的是 RGB 三维空间的欧氏距离,结果范围从 0 到约 1.732。所以_KeepRange的合理区间通常在 0.1 到 0.5 之间,而不是 1.0。
3.5 装配到场景
场景里的操作顺序也容易踩坑,列出来给你抄作业:
- 在场景创建一个 Global Volume,Volume Profile 里点 Add Override,选 Custom Post > Keep Color。
- 新建一个 Material,Shader 选 CustomPost/KeepColor。
- 在 Forward Renderer 资产上点 Add Renderer Feature,选 KeepColorFeature,把 Material 拖到 Settings 的 material 栏。
- 回到 Volume 组件,把 Intensity 调到 1,TargetColor 设成画面里你想保留的颜色,KeepRange 先给 0.2 左右,KeepSmooth 给 0.1。
- 运行游戏,画面里接近目标色的区域保留彩色,其他地方变成灰度。
调参时优先调 KeepRange 和 KeepSmooth,不要一上来就动 Target Color 的 Alpha 通道,ColorParameter的 Alpha 在后处理 Shader 里默认没有实际用途。
4. 参数计算与细节:为什么你的效果总是差一点点
一套能跑的后处理只是开始。真正折磨人的是颜色不对、边缘闪、移动端上糊。这一节把几个最常见的坑拆开讲。
4.1 色彩空间:Linear 与 Gamma 一混,颜色就飘
URP 默认使用 Linear 色彩空间做光照和渲染,而你在 Volume 参数里选的 Target Color,其实是美术在 sRGB 空间里选的色值。一个人在调色面板里选出来的 0.3、0.6、0.9,编辑器显示的就是 sRGB 编码。但 Shader 里采样出来的_MainTex像素,如果渲染目标是 HDR 线性 RT,拿到的就是线性光照值。
问题来了:我们用线性像素值和 sRGB 的目标色去算距离,两套数值不在同一把尺子上。结果就是同一个色相,在亮部和暗部的保留范围完全不同,画面看上去像是"这个红能留下,那个红却被吞掉"。处理办法有两条路:要么在 Shader 里把目标色用SRGBToLinear转到线性空间再去算距离,要么把采样像素先切成 sRGB 空间再判断。我在实际项目里更常用第二种,因为美术在面板上看到的目标色,就是最终屏幕上看到的颜色,按这个直觉来最不容易翻车。
4.2 RT 尺寸、MSAA 与采样模式
cameraTargetDescriptor是从相机当前目标拷贝出来的。如果相机开了 MSAA 4x,这个 descriptor 的msaaSamples也是 4,临时 RT 直接拿这个 descriptor 分配就会变成一个带 MSAA 的 RT,后面 Blit 回来时可能出现模糊或脏边缘。所以我第一行就写desc.msaaSamples = 1,强制中间 RT 不带 MSAA。
desc.depthBufferBits = 0是因为后处理 Shader 不读深度,RT 不分配深度贴图能省不少内存。FilterMode.Bilinear适合灰度保留色这类颜色连续过渡的效果;如果你要做像素化特效,就要换Point,否则格子边缘会被插值糊掉。
如果想要更低的分辨率来换取性能,可以直接改desc.width和desc.height,全屏效果变成半分辨率做,相当于自动做了一次降采样加双线性放大。很多移动端后处理会用这个策略。
4.3 距离阈值怎么定
前面说过,distance在 RGB 空间里最大值是sqrt(3),约 1.732。把_KeepRange调到 1.0 的时候,基本上全屏都在保留范围里,只有极端黑的白色会被灰度化。所以默认值建议放在 0.2 左右,再根据画面具体微调。
_KeepSmooth控制过渡带的宽度。它被用成了smoothstep的上下界,也就是_KeepRange - _KeepSmooth * 0.5和_KeepRange + _KeepSmooth * 0.5。这个值给到 0.05 到 0.2 之间最稳,太小会看到明显色阶边界,太大则所有颜色的区分感都会被磨掉。
另外,RGB 距离法最大的缺陷是:同一颜色的亮部、暗部,距离中心色的值会不一样。如果你做的是"保留肤色""保留血迹"这类对色相敏感的玩法,建议把颜色转到 HSV 之后只比较色相通道,而不是直接用 RGB 距离。高光、阴影下的目标色距离会变很大,用 RGB 距离很容易把本该保留的区域也变成灰度。
4.4 全屏后处理的性能常识
全屏后处理在 1080p 分辨率下,Frag 的调用量大概二百万像素起步,每在 Shader 里加一次SAMPLE_TEXTURE2D,这个百万级基数就要再乘一次。所以别在 Fragment 里写大循环,尤其别在循环里多次采样纹理。能用half的变量就别用float,移动端 GPU 对 half 的吞吐明显更好。
Material 属性赋值也要注意,_material.SetFloat这种操作应该在 Execute 里按相机执行一次,不是在 Shader 里每像素执行。如果你的效果需要很多参数,优先考虑用 Volume 参数一次性塞进 MaterialPropertyBlock,减少不必要的材质拷贝。
5. 常见问题与排查技巧实录
做后处理最容易遇到的问题,基本上就是下面这几个。我把排查顺序和原因做成速查表,你照着查就行。
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 画面完全没有变化 | Renderer Feature 没挂 / Volume 组件没加 / Intensity 为 0 | 按顺序检查三个点 |
| 黑屏或白屏 | Material 没赋 / Shader 编译报错 / 临时 RT 泄漏 | 看 Console;直接输出_MainTex验证链路 |
| 上下颠倒 | 平台 UV 坐标差异 | 启用 Shader 里注释掉的翻转判断 |
| 颜色发灰发飘 | 色彩空间混用 | 统一 Linear 或 sRGB 再比较 |
| 边缘闪烁、模糊 | 临时 RT 用了带 MSAA 的 descriptor | 设置desc.msaaSamples = 1 |
| 移动端上黑屏 | Shader 被裁剪 | 材质引用的 Shader 加进 Always Included Shaders |
| 新 URP 版本报错 | 经典 Execute 路径和 RenderGraph API 冲突 | 到 URP Global Settings 关闭 Render Graph,或迁移到新 API |
检查链路时,我有个百试百灵的小技巧:先把 Fragment 里所有效果逻辑注释掉,直接return SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv);。如果这时候画面和原画完全一致,说明数据链路没问题,问题只出在后续的颜色运算上;如果原画都不对,说明是采样、RT 或 Blit 方向的问题,那就和颜色逻辑无关。
Shader 被裁剪的问题在 Release 构建里很常见。如果你只在编辑器里看到后处理,打包到真机上黑屏,先看 Console 有没有 Shader 相关的报错。全屏后处理的 Material 如果只在 Renderer Feature 上被引用,而它的 Shader 没有被任何场景物体使用,打 AB 包或构建时可能被 Unity 的 Shader stripping 干掉了。解决办法是把该 Shader 加进 Project Settings > Graphics 的 Always Included Shaders,或者确保材质被资源管理器正确依赖。
还有一点,URP 版本之间差异真的很大。12.x、14.x、17.x 里很多 API 的签名都变过,网上复制来的代码直接报错太正常了,不是你写错了。本文的代码以 URP 12 到 14 的经典 Execute 路径为准,这也是现有项目里最多的写法。如果你的工程提示包括 RenderGraph、RecordRenderGraph、CommandBuffer 的 Blit 已标记过时,先看一眼 URP Global Settings 里能不能把 Render Graph 关闭;能关就关,不能关就按官方迁移说明改。
6. 从这一个后处理出发,能延伸出多少玩法
灰度保留色只是自定义后处理的一个最小样本。把套路吃透之后,你可以很快扩展出一堆风格化玩法。这里给你几条已经验证过的路径。
6.1 像素化
像素化的思路很简单:把采样 UV 强行取整到某个网格里。Shader 里加一个float _PixelCount,然后采样时用float2 uvPixel = floor(input.uv * _PixelCount) / _PixelCount;。这个技巧和灰度保留色结合,就是像素画风格的"红色警报"滤镜。注意像素化特效的临时 RT 滤波模式建议用 Point,这样不会出现格子内部插值后的大面积色块。
6.2 区域 Mask
后处理不一定是整屏统一的。你完全可以在 Volume 组件里加一个TextureParameter,在 Shader 里多采一张 Mask 纹理,让前面算出来的灰度强度乘上一个遮罩值。比如屏幕只有主角周围一圈保持彩色,远处全部变成黑白;或者某个危险区域进入后画面发生偏移。这种玩法特别适合做关卡机制,因为后处理不再只是视觉装饰,而是能直接影响玩家对信息的读取。
6.3 多 Pass 链式处理
一次 Blit 解决不了的运算,拆成多次 Blit。径向模糊、高斯模糊这类卷积操作,通常会先横向模糊到临时 RT,再纵向模糊回相机目标。Pass 里用状态变量控制当前执行的是第几段,中间结果都通过临时 RT 传递。本质上,每多一个 Pass 就是多一次全屏 DrawCall,所以串联多个效果时要心里有数:三个后处理就是三张全屏 RT 的开销,移动端很容易踩性能红线。
6.4 什么时候用内置 Full Screen Pass
新版 URP 的 Renderer 自带 Full Screen Pass Renderer Feature,可以直接选材质做全屏后处理,不需要写 C#。如果只是临时调色、加噪点,用它特别快。但一旦你需要读 Volume 参数、做多相机差异化、动态切换效果强度,手写 Feature 会灵活得多。我的习惯是:原型验证用内置组件,进正式玩法后必须改成手写方式,因为内置组件把控制权藏得太深了。
每次搭新项目,我个人都习惯先建一个空场景,只放一个球和一面墙,把后处理强度调到最大,确认这条链路是通的,再开始写业务逻辑。后处理是所有画面的最后一层,它一黑屏,你完全没法判断是相机问题、灯光问题还是模型问题。先把链路打通,后面加效果就只是加参数的事。灰度保留色虽然简单,但它身上带着后处理所有关键问题:时机、参数、采样、RT。把这个案例吃透,URP 的自定义后处理就不再是一堆谜一样的 API,而是一条你能随时拆开检修的流水线。