资讯动态

Unity UGUI 自定义 Shader 实现六种 UI 后处理效果

发布时间:2026/10/14 16:18:00 来源:尧图企业网站定制
简介这份资源是面向 Unity 开发者与 UI 设计师的 UGUI 特效源码工程针对传统 UI 依赖静态图片与简单动画、表现力不足的痛点提供一套可直接复用的视觉增强方案。工程基于 Unity 2021.3.27f1c2 Personal 版本需 Unity 2020.3 及以上环境通过 Inspector 勾选即可为 Image、RawImage、Text、TextMeshProUGUI 等组件添加灰度、复古、模糊、边缘发光、溶解、闪光等 20 余种预设特效无需手写复杂 Shader并支持正片叠底、叠加等 10 余种混合模式与多重模糊阴影、动态偏移性能优于原生阴影。压缩包共 822 个文件约 3.56MB其中 119 个 cs 脚本承载核心逻辑62 个 shader 与 5 个 cginc 实现渲染效果66 个 prefab 与 18 个 unity 场景便于快速预览另有 png、jpg、ttf 等素材及 asmdef、json 配置目录按模块划分清晰。目前已有 627 人学习适合希望提升界面表现力、减少 Shader 编写成本的中高级开发者参考与二次开发。1. UGUI 后处理效果到底在解决什么问题很多做 Unity 项目的同学都遇到过这种需求策划跑过来要一个「按钮置灰」的效果美术想要一个「界面溶解消失」的转场主程又要求「低血量时屏幕边缘泛红发光」。第一反应往往是去改材质、换 Shader、或者干脆让美术出两套图。但项目一旦进入中后期UI 数量爆炸靠换图或改材质根本维护不过来。UGUI 的Image、Text、RawImage这些组件本质上都是走 Canvas 合批渲染的默认材质是UI/Default。想在不破坏合批、不额外增加 DrawCall 的前提下给单个 UI 元素加灰度、模糊、溶解、边缘发光这些效果最靠谱的路子就是写自定义 Shader然后通过Material挂到Graphic上。这套思路在业内已经非常成熟核心就是利用 UGUI 的IMaterialModifier接口和VertexHelper的顶点数据把效果参数塞进顶点色或 UV 通道里让同一个 Shader 能同时处理多个不同参数的 UI 元素。这篇文章面向的是已经会用 Unity、写过简单 Shader、但对 UGUI 渲染管线细节不太熟的开发者。我会把灰度、复古、模糊、边缘发光、溶解、闪光这六种效果的实现思路、Shader 关键代码、C# 控制脚本、参数调优和踩坑记录全部拆开讲。看完之后你应该能自己搭一套可复用的 UIEffect 组件而不是每个效果都去网上抄一份互不兼容的代码。2. 六种 UIEffect 的 Shader 实现与参数拆解2.1 灰度与复古一个_EffectAmount参数控制强度灰度效果的本质是把像素的 RGB 通道按亮度权重混合成单一值。常见做法是用dot(color.rgb, float3(0.299, 0.587, 0.114))得到亮度再和原色做lerp。复古效果则是在灰度基础上叠加一层棕黄色调同时降低对比度。// UIEffect_GrayRetro.shader 片段 fixed4 frag(v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv) * i.color; // 灰度计算人眼对绿色最敏感红色次之蓝色最弱 fixed gray dot(col.rgb, fixed3(0.299, 0.587, 0.114)); // 复古色调把灰度值映射到棕黄区间 fixed3 retroColor fixed3(gray * 1.1, gray * 0.95, gray * 0.75); // _EffectAmount 控制灰度强度0 为原色1 为完全灰度 fixed3 finalRGB lerp(col.rgb, retroColor, _EffectAmount); return fixed4(finalRGB, col.a * _Alpha); }_EffectAmount这个参数建议暴露在材质面板上范围 0 到 1。实际项目里经常用它做「按钮从彩色渐变到灰色」的过渡动画比直接切换两张图要平滑得多。注意i.color是顶点色UGUI 会把Graphic.color和 Canvas 的renderer.color乘进去所以如果你的 UI 本身有透明度动画这里要保留乘法。复古效果还有一个变体是加扫描线用frac(i.uv.y * _ScanLineCount)做周期性条纹再乘到颜色上。扫描线密度建议控制在 200 到 400 之间太低会像百叶窗太高在移动端会产生摩尔纹。2.2 模糊两遍高斯还是单遍采样UI 模糊和屏幕后处理模糊不一样UI 模糊通常只需要对单个元素做处理不需要全屏 GrabPass。最简单的做法是在片元着色器里做多次纹理采样按高斯权重累加。// 单遍 9 采样高斯模糊_BlurRadius 控制采样偏移 fixed4 frag(v2f i) : SV_Target { fixed4 col fixed4(0,0,0,0); fixed2 texelSize _MainTex_TexelSize.xy * _BlurRadius; // 3x3 高斯核权重和为 1 col tex2D(_MainTex, i.uv fixed2(-1,-1) * texelSize) * 0.0625; col tex2D(_MainTex, i.uv fixed2( 0,-1) * texelSize) * 0.125; col tex2D(_MainTex, i.uv fixed2( 1,-1) * texelSize) * 0.0625; col tex2D(_MainTex, i.uv fixed2(-1, 0) * texelSize) * 0.125; col tex2D(_MainTex, i.uv) * 0.25; col tex2D(_MainTex, i.uv fixed2( 1, 0) * texelSize) * 0.125; col tex2D(_MainTex, i.uv fixed2(-1, 1) * texelSize) * 0.0625; col tex2D(_MainTex, i.uv fixed2( 0, 1) * texelSize) * 0.125; col tex2D(_MainTex, i.uv fixed2( 1, 1) * texelSize) * 0.0625; return col * i.color; }_BlurRadius建议范围 0 到 5超过 5 之后单遍采样的模糊质量会明显下降出现块状伪影。如果项目对模糊质量要求高可以改成两遍第一遍横向采样第二遍纵向采样每遍 5 个采样点就能达到 9 采样单遍的效果而且采样次数从 9 降到 10 但质量更好。不过两遍模糊需要额外的 RenderTexture在 UGUI 里实现起来会复杂一些需要用到CommandBuffer或者Blit。注意UI 模糊在移动端要慎用尤其是低端机。每次纹理采样都是带宽消耗9 次采样在 1080p 下对 GPU 的压力不小。如果只是做背景模糊建议直接用一张预先模糊好的贴图而不是实时计算。2.3 边缘发光用 UV 导数还是距离场边缘发光的核心是检测像素是否处于图形边缘。在 UGUI 里Image的 UV 是 0 到 1 均匀分布的但图形边缘并不一定在 UV 边界上。所以不能简单用uv.x 0.1来判断。比较可靠的做法是用fwidth或者手动计算相邻像素的 Alpha 差值。如果当前像素 Alpha 高但周围像素 Alpha 低说明这里是边缘。// 边缘发光通过 Alpha 梯度检测边缘 fixed4 frag(v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv) * i.color; // 采样上下左右四个方向的 Alpha fixed alphaU tex2D(_MainTex, i.uv fixed2(0, _MainTex_TexelSize.y)).a; fixed alphaD tex2D(_MainTex, i.uv - fixed2(0, _MainTex_TexelSize.y)).a; fixed alphaL tex2D(_MainTex, i.uv - fixed2(_MainTex_TexelSize.x, 0)).a; fixed alphaR tex2D(_MainTex, i.uv fixed2(_MainTex_TexelSize.x, 0)).a; // 梯度越大越接近边缘 fixed edge abs(alphaU - alphaD) abs(alphaL - alphaR); edge saturate(edge * _EdgeWidth); // _GlowColor 是发光颜色_GlowIntensity 控制强度 fixed3 glow _GlowColor.rgb * edge * _GlowIntensity; return fixed4(col.rgb glow, col.a); }_EdgeWidth控制边缘检测的灵敏度值越大边缘越粗。_GlowIntensity建议不超过 3太高会过曝。这个方案对纯色图形效果很好但如果 UI 图片本身有半透明渐变区域边缘检测会误判这时候需要额外传一张 Alpha 遮罩图来限定检测范围。2.4 溶解噪声图加阈值裁剪溶解效果是很多游戏里角色死亡、道具消失的标配。原理很简单采样一张噪声图用噪声值和_DissolveAmount比较小于阈值的像素直接clip掉。// 溶解效果噪声图 阈值裁剪 边缘燃烧 fixed4 frag(v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv) * i.color; // 采样噪声图_NoiseTex 需要设置为 Repeat 模式 fixed noise tex2D(_NoiseTex, i.uv * _NoiseScale).r; // _DissolveAmount 从 0 到 1 控制溶解进度 fixed threshold _DissolveAmount; // 小于阈值的像素裁掉 clip(noise - threshold); // 在阈值附近生成燃烧边缘 fixed edge 1 - saturate((noise - threshold) / _EdgeWidth); fixed3 burnColor _BurnColor.rgb * edge * _BurnIntensity; return fixed4(col.rgb burnColor, col.a); }_NoiseScale控制噪声图的平铺密度建议 1 到 3 之间。_EdgeWidth是燃烧边缘的宽度太小会看不到边缘太大整个图形都会发光。_BurnColor一般用橙红色配合_BurnIntensity做出灼烧感。注意clip函数在移动端某些 GPU 上性能较差因为它会打断 Early-Z。如果项目对性能敏感可以用discard替代或者把溶解做成 Alpha 混合而不是裁剪。2.5 闪光UV 扫光加时间控制闪光效果通常用于按钮高亮、卡片翻转、奖励领取这些场景。做法是用一个随时间移动的 UV 区间在这个区间内叠加白色。// 闪光效果斜向扫光 fixed4 frag(v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv) * i.color; // 把 UV 旋转 45 度让扫光斜向移动 fixed2 rotatedUV fixed2(i.uv.x i.uv.y, i.uv.y - i.uv.x) * 0.5; // _Time.y 是 Unity 内置时间_FlashSpeed 控制扫光速度 fixed flashPos frac(_Time.y * _FlashSpeed); // 计算当前像素到扫光中心的距离 fixed dist abs(rotatedUV.x - flashPos); // _FlashWidth 控制扫光宽度 fixed flash 1 - saturate(dist / _FlashWidth); flash pow(flash, _FlashPower); return fixed4(col.rgb flash * _FlashColor.rgb, col.a); }_FlashSpeed建议 0.5 到 2 之间太快会像频闪太慢又不够明显。_FlashWidth控制扫光带的宽度一般 0.1 到 0.3。_FlashPower用来调整扫光的锐利程度值越大边缘越硬。2.6 把六种效果塞进一个 Shader 的取舍实际项目里不可能每个效果一个 Shader那样变体太多打包和加载都会出问题。常见的做法是用#pragma multi_compile或者shader_feature来管理变体或者干脆用一个 Shader 加多个_EffectType分支。// 用 _EffectType 控制效果分支0无1灰度2复古3模糊4边缘发光5溶解6闪光 fixed4 frag(v2f i) : SV_Target { fixed4 col tex2D(_MainTex, i.uv) * i.color; if (_EffectType 1) { fixed gray dot(col.rgb, fixed3(0.299, 0.587, 0.114)); col.rgb lerp(col.rgb, gray, _EffectAmount); } else if (_EffectType 2) { fixed gray dot(col.rgb, fixed3(0.299, 0.587, 0.114)); fixed3 retro fixed3(gray * 1.1, gray * 0.95, gray * 0.75); col.rgb lerp(col.rgb, retro, _EffectAmount); } // ... 其他分支 return col; }这种写法的缺点是 GPU 分支会有性能开销而且所有效果的代码都会编译进最终 Shader。如果项目里效果种类多建议用shader_feature把每种效果做成独立变体运行时通过Material.EnableKeyword切换。这样打包时只会包含实际用到的变体缺点是切换效果时需要重新编译 Shader第一次切换会有卡顿。3. C# 控制脚本让效果动起来3.1 继承 BaseMeshEffect 还是直接改 MaterialUGUI 提供了BaseMeshEffect抽象类可以通过修改VertexHelper来改变顶点数据。但灰度、模糊这些效果是像素级的改顶点没用。所以更直接的方式是继承MonoBehaviour并实现IMaterialModifier接口。using UnityEngine; using UnityEngine.UI; [RequireComponent(typeof(Graphic))] public class UIEffect : MonoBehaviour, IMaterialModifier { public enum EffectType { None, Gray, Retro, Blur, EdgeGlow, Dissolve, Flash } [SerializeField] private EffectType effectType EffectType.None; [SerializeField] private float effectAmount 1f; [SerializeField] private Color glowColor Color.white; [SerializeField] private float glowIntensity 1f; private Graphic graphic; private Material runtimeMaterial; private void Awake() { graphic GetComponentGraphic(); } // IMaterialModifier 接口UGUI 在渲染前会调用这个方法 public Material GetModifiedMaterial(Material baseMaterial) { if (effectType EffectType.None) return baseMaterial; // 每个 Graphic 实例需要独立的 Material避免参数互相影响 if (runtimeMaterial null) { runtimeMaterial new Material(baseMaterial); runtimeMaterial.hideFlags HideFlags.HideAndDontSave; } // 把效果参数写入 Material runtimeMaterial.SetFloat(_EffectType, (float)effectType); runtimeMaterial.SetFloat(_EffectAmount, effectAmount); runtimeMaterial.SetColor(_GlowColor, glowColor); runtimeMaterial.SetFloat(_GlowIntensity, glowIntensity); return runtimeMaterial; } // 运行时修改参数后需要通知 Graphic 重建材质 public void SetEffectAmount(float amount) { effectAmount amount; graphic.SetMaterialDirty(); } private void OnDestroy() { if (runtimeMaterial ! null) DestroyImmediate(runtimeMaterial); } }IMaterialModifier是 UGUI 提供的接口Graphic在渲染前会遍历所有实现了这个接口的组件把它们的GetModifiedMaterial返回值作为最终材质。这里有几个关键点第一每个Graphic必须有自己的 Material 实例否则多个 UI 元素会共享同一个材质参数会互相覆盖。第二修改参数后必须调用graphic.SetMaterialDirty()否则 UGUI 不会重新获取材质。第三runtimeMaterial要设置HideFlags.HideAndDontSave避免被场景保存和资源卸载影响。3.2 用 MaterialPropertyBlock 还是独立 Material上面代码里用的是独立 Material每个 UI 元素一个。如果场景里有几百个 UI 元素都挂了这个脚本就会产生几百个 Material 实例内存和 DrawCall 都会爆炸。替代方案是用MaterialPropertyBlock。MaterialPropertyBlock可以在不创建新 Material 的情况下覆盖材质属性而且支持 GPU Instancing。但 UGUI 的默认渲染路径并不直接支持MaterialPropertyBlock需要自己写Graphic的子类或者用CanvasRenderer.SetMaterial配合SetPropertyBlock。// 使用 MaterialPropertyBlock 的替代方案 public Material GetModifiedMaterial(Material baseMaterial) { if (effectType EffectType.None) return baseMaterial; if (propertyBlock null) propertyBlock new MaterialPropertyBlock(); propertyBlock.SetFloat(_EffectType, (float)effectType); propertyBlock.SetFloat(_EffectAmount, effectAmount); // 注意UGUI 的 CanvasRenderer 需要手动设置 PropertyBlock var canvasRenderer graphic.canvasRenderer; canvasRenderer.SetPropertyBlock(propertyBlock); return baseMaterial; }这个方案的好处是所有 UI 元素共享同一个 MaterialDrawCall 可以合批。缺点是MaterialPropertyBlock在某些 Unity 版本和渲染管线下对 UGUI 的支持不完整需要实测。我一般会在项目初期就用MaterialPropertyBlock方案如果发现效果不对再回退到独立 Material。3.3 效果参数的动画控制灰度、溶解、闪光这些效果经常需要做动画。最简单的做法是用Animator或者DOTween直接改effectAmount字段然后调用SetMaterialDirty。// 用协程做一个溶解动画 public IEnumerator PlayDissolve(float duration) { float elapsed 0f; while (elapsed duration) { elapsed Time.deltaTime; float t Mathf.Clamp01(elapsed / duration); SetEffectAmount(t); yield return null; } SetEffectAmount(1f); }如果项目里用了DOTween可以直接DOTween.To(() effectAmount, x SetEffectAmount(x), 1f, duration)。注意SetEffectAmount里调用了SetMaterialDirty每帧调用会有一定开销但实测在几百个 UI 元素以内是可以接受的。注意SetMaterialDirty会触发 Canvas 的重新合批如果一帧内大量 UI 元素同时调用会造成卡顿。建议把动画分散到多帧或者用Canvas.willRenderCanvases事件统一处理。4. 避坑与排查那些让我加班到凌晨的细节4.1 灰度效果在 Text 上不生效现象给Text组件挂上 UIEffect 脚本设置灰度效果但文字颜色没变化。原因Text组件默认使用的材质是UI/Default Font而不是UI/Default。IMaterialModifier返回的材质会被 UGUI 用来渲染但如果 Shader 里的属性名和Text期望的不一致就会出问题。另外Text的顶点色包含了字体颜色如果 Shader 里没有正确处理i.color灰度计算会出错。解决确保自定义 Shader 的Properties块里包含了_MainTex和_Color并且片元着色器里用i.color而不是_Color。如果还是不行检查Text的Material是否被其他脚本覆盖了。4.2 溶解边缘出现黑边现象溶解效果在图形边缘出现一圈黑色而不是预期的燃烧色。原因clip裁掉像素后剩下的像素 Alpha 值可能不是 1导致和背景混合时出现黑边。另外如果噪声图的 Wrap Mode 不是 RepeatUV 超出 0 到 1 范围时会采样到边界值产生条纹。解决在clip之前先把 Alpha 归一化或者在clip之后强制col.a 1。噪声图的 Import Settings 里把 Wrap Mode 改成 RepeatFilter Mode 改成 Bilinear。如果还有黑边检查 Canvas 的 Render Mode 是不是Screen Space - Camera这种模式下 UI 和 3D 物体的深度混合可能会出问题。4.3 模糊效果在移动端糊成一片现象在编辑器里模糊效果正常打包到手机上后整个 UI 都糊了连不需要模糊的元素也受影响。原因_MainTex_TexelSize在移动端可能返回错误的值导致采样偏移过大。另外如果 UI 图集的 Texture 设置了 Mip Maps低分辨率下的采样会取到模糊的 Mip 层级。解决不要依赖_MainTex_TexelSize改成手动传一个_BlurOffset参数在 C# 里根据屏幕分辨率计算。UI 图集的 Mip Maps 建议关闭或者把_BlurRadius限制在 2 以内。4.4 多个 UIEffect 叠加时参数互相覆盖现象同一个 UI 元素上挂了两个 UIEffect 脚本一个控制灰度一个控制闪光结果只有一个生效。原因IMaterialModifier接口在同一个Graphic上只会保留最后一个返回值。如果两个脚本都返回了新的 MaterialUGUI 只会用最后一个。解决把多个效果合并到一个 UIEffect 脚本里用_EffectType或者多个布尔开关控制。如果确实需要拆分可以让一个脚本负责创建 Material另一个脚本只修改参数通过GetComponentUIEffect()获取实例。4.5 Shader 变体太多导致打包失败现象项目里 UIEffect 的 Shader 用了大量shader_feature打包时 Shader 变体数量爆炸打包时间从几分钟变成几十分钟甚至打包失败。原因shader_feature会为每个关键字组合生成一个变体如果关键字数量是 N变体数量就是 2 的 N 次方。六种效果如果各用一个关键字就是 64 个变体再加上其他关键字很容易超过 Unity 的变体上限。解决用multi_compile替代shader_feature并且在Graphics Settings里设置Shader Stripping把不需要的变体裁掉。或者干脆不用关键字用一个_EffectType浮点数在运行时分支。虽然 GPU 分支有开销但比变体爆炸要好。5. 进阶用 RenderTexture 做全屏 UI 后处理单个 UI 元素的效果用上面的方案就够了但如果想做全屏 UI 的后处理比如整个界面变灰、整个界面模糊就需要用到 RenderTexture 和CommandBuffer。思路是把 Canvas 的渲染目标设为一个 RenderTexture然后用一个全屏的Image或者RawImage显示这个 RenderTexture并在它的材质里做后处理。具体步骤是创建一个Camera专门渲染 UI 层设置targetTexture为 RenderTexture在场景里放一个CanvasRender Mode设为Screen Space - Overlay里面放一个全屏RawImagetexture指向那个 RenderTexture给RawImage挂一个后处理材质在材质里做灰度、模糊等操作。// 全屏 UI 后处理的控制脚本 public class FullScreenUIEffect : MonoBehaviour { public Camera uiCamera; public RawImage displayImage; public Material postProcessMaterial; private RenderTexture rt; private void Start() { // 创建和屏幕等大的 RenderTexture rt new RenderTexture(Screen.width, Screen.height, 0); uiCamera.targetTexture rt; displayImage.texture rt; displayImage.material postProcessMaterial; } private void OnDestroy() { if (rt ! null) { uiCamera.targetTexture null; rt.Release(); Destroy(rt); } } }这个方案的性能开销比单个 UI 元素的效果大得多因为多了一次全屏的 RenderTexture 读写。在移动端要慎用尤其是低端机。如果只是做灰度完全可以用单个 UI 元素的方案逐个处理没必要上全屏后处理。验证效果是否正确的一个技巧是在 Scene 视图里把Shading Mode设为Unlit然后逐个选中 UI 元素看Inspector里的Material是不是预期的自定义材质。如果材质不对说明IMaterialModifier没有被正确调用检查脚本是否继承了IMaterialModifier并且GetModifiedMaterial返回了非空值。我自己的习惯是每加一种新效果先在编辑器里用一个纯色Image测试确认 Shader 逻辑没问题后再挂到实际 UI 上。这样能快速定位是 Shader 的问题还是 UGUI 集成的问题。另外所有效果参数都要在Inspector里暴露出来方便策划和美术自己调不要写死在代码里。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑