恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
URP自定义后处理从Renderer Feature到灰度保留色实战
首页
资讯中心
/
URP自定义后处理从Renderer Feature到灰度保留色实战
URP自定义后处理从Renderer Feature到灰度保留色实战
发布时间:2026/10/21 3:12:59
如果你是从内置渲染管线转过来写 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 PassRenderer 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.GetComponentKeepColorEffect(); 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 写屏幕特效 ShaderShader 这边只需要一个 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 VolumeVolume Profile 里点 Add Override选 Custom Post Keep Color。新建一个 MaterialShader 选 CustomPost/KeepColor。在 Forward Renderer 资产上点 Add Renderer Feature选 KeepColorFeature把 Material 拖到 Settings 的 material 栏。回到 Volume 组件把 Intensity 调到 1TargetColor 设成画面里你想保留的颜色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而是一条你能随时拆开检修的流水线。