游戏UI灰度化全攻略:Shader原理、UGUI实现与性能优化 📅 发布时间:2026/9/18 19:47:52 👁 浏览次数: 搞游戏 UI 的同学大概率都遇到过这种需求角色头像在组队离线时置灰、按钮在 CD 中置灰、商店物品未解锁置灰……灰度化几乎是所有游戏 UI 的必备功能。网上教程不少但大多只丢一个 Shader 代码讲不清原理直接拿去用还会踩 Mask 失效、材质实例泄漏、合批被打断这些坑。这篇博文不玩虚的直接从需求场景讲起把灰度化的公式原理、UGUI Shader 的关键特性、完整代码、C# 封装、性能优化到排坑实录全部过一遍。我自己在项目里用这套方案做了头像、按钮、道具图标三类灰度化跑了两年的线上版本也经历过 UI 卡顿、Mask 失效、半透明图片灰完发白这些经典问题。跟着这篇文章走一遍你既能拿到能直接用的代码也能明白每一行 Shader 和每一个 C# 方法背后的道理。1. 灰度化方案选型为什么最终选 Shader1.1 灰度化的真实业务场景在动手写代码之前得先想清楚灰度化到底用在哪些地方不同场景对灰度化的要求其实不一样。我遇到过的需求主要分四类。第一类是离线状态展示比如好友列表里的头像好友不在线就灰掉玩家看到的第一反应是“这个人不在线”而不是“我手机坏了”。这类需求要求灰度是完整的、彻底的不能残留明显的彩色痕迹而且一般不需要过渡动画。第二类是功能锁定比如主界面底部的功能按钮等级不够就不能点除了置灰可能还要加一个锁的图标这类需求一般配合点击拦截器一起用。第三类是倒计时或冷却状态比如技能按钮在 CD 中很多项目会做成部分灰度或者用“灰度 透明度变化”的叠加效果带一点渐变过程让玩家感知到状态在变化。第四类是活动结束后的入口比如限时活动入口关闭整块 UI 置灰这种往往需要连文字一起灰掉。这些场景汇总下来对灰度化的技术需求有五个能动态切换灰度与彩色、支持任意 Sprite 图片、不破坏 UGUI 的 Mask/RectMask2D 裁剪、不显著增加 Draw Call、适配移动端 GPU。1.2 三种主流实现方案对比方案一美术直接出灰色图。简单粗暴打包体积多一份资源而且没法动态过渡如果做冷却渐入渐出美术得准备 N 张图。这方案基本只能应对“永久置灰”的静态需求遇到动态切换就抓瞎。方案二运行时替换带灰度的材质变体。比如预先创建一个 Gray.mat 材质把 Image.material 替换成它。好处是效率高缺点是一旦 Image 本身挂了 Mask 组件材质里没有正确的 Stencil 设置裁剪直接失效灰块会穿透遮罩漏出来。而且在 UI 上每多换一个材质往往就多打断一次合批尤其列表页头像一多帧率立马往下掉。方案三写一个支持灰度参数的 UI Shader通过脚本控制材质的灰度强度。这是目前项目里最主流的做法原因是它解决了两个非常核心的痛点第一灰度强度是运行时变量从 0 到 1 可以做任意过渡动画完全覆盖冷却、解锁、选中态各种需求第二Shader 里可以内置 Stencil 相关代码和 UGUI 的 Mask 机制兼容避免灰色块穿透遮罩的尴尬。综合下来我的结论很明确只要有动态切换灰度状态的需求就值得用 Shader 方案。美术出图只适合极少数静态场景替换材质变体适合原型验证不适合正式项目铺开用。2. Shader 原理拆解灰度公式与 UGUI 特性2.1 灰度化公式从人眼感知说起灰度化本质上是把 RGB 三个通道压缩成一个亮度值。最简单的做法是取平均值(r g b) / 3但人眼对绿色最敏感对蓝色最不敏感所以平均值法算出来的灰色在视觉上会偏亮偏白尤其对红蓝对比明显的图片灰完之后层次感会很差。业界最常用的公式是 Rec.709 亮度系数也就是Y 0.299r 0.587g 0.114b。这个公式来自于电视广播标准在图形学里也是通用的 Luminance 计算方式Unity 内置的许多后处理效果用的也是这套权重。在 Shader 里我会写成浮点乘法float luma dot(col.rgb, fixed3(0.299, 0.587, 0.114));用dot的好处是 GPU 上一条指令就能完成三个乘法再加和效率比手写三个col.r * 0.299 col.g * 0.587 col.b * 0.114更高而且代码更简洁。实际测试下来通过这套权重灰出的图片明暗过渡最接近黑白照片的感觉观感最自然。当然如果你有特殊的调色需求也可以自己改权重。比如有些项目想要“冷酷感”更强的灰度会把蓝色通道权重再压低一些让整体偏冷灰也有项目会在灰度之后叠加一个轻微的颜色偏移模拟特定氛围。这个度完全由项目自己把握公式本身只是工具。2.2 支持灰度的 UGUI 基础 Shader 必备四要素一个能直接用在 UGUI Image 上的 Shader光会算灰没用还得先满足 UGUI 渲染的底层要求。第一个要素是透明混合。UI 上绝大多数图片都是带 Alpha 的半透明图哪怕是一张看起来“方形”的图边缘也会有圆弧抗锯齿。如果 Shader 里不开启混合图片边缘会出现难看的硬边和锯齿。标准做法是Blend SrcAlpha OneMinusSrcAlpha第二个要素是关闭深度写入。ZWrite Off是 UI 渲染的潜规则。UI 元素之间本来就不应该互相遮挡深度缓冲区对 UI 来说意义不大而且一旦写了深度半透明元素之间的排序就会出问题文字被图片挡住之类的怪事就来了。第三个要素是双面渲染。虽然 UGUI 的 Image 很少出现背面的情况但旋转动画在某些角度下会导致面片翻转默认的 Cull Back 会直接剔除背面画面就“消失”了。所以写 UI Shader 我习惯性加一句Cull Off防止旋转动画玩脱。第四个要素是支持图集和 Sprite 的默认纹理坐标。Unity 对 Sprite 图集有专门的宏和标签CanUseSpriteAtlas如果 Shader 里不加这个标签Sprite 打包进图集之后 UV 会错乱显示成乱七八糟的一坨。太简单的 Shader 经常踩这个坑。2.3 Stencil 缓冲区与 UGUI Mask 的兼容机制这是最容易踩坑也最容易被教程忽略的地方。Unity 的 Mask 组件和 RectMask2D 组件底层实现其实是完全不同的两套机制。RectMask2D 走的是 Shader 里的_UseUIAlphaClip分支在片元着色器里根据矩形范围做 clip而 Mask 组件走的是 Stencil 缓冲区渲染 Mask 自身时写入模板值渲染被遮罩的子物体时比对模板值不在范围内的像素直接丢弃。因为灰度的 Shader 往往会自己手写很多教程只贴了核心灰色计算没有把 Stencil 那段代码抄进去结果就是你给一个挂在 Mask 下的 Image 用上灰度材质灰色的方块直接穿到遮罩外面丑得没法看。解决方案很简单把 Unity 内置UI/DefaultShader 里的 Stencil 块原样复制过来。在后面的完整代码里我会保留这整段 Stencil 配置确保灰度材质放到任意 Mask 结构下都正常裁剪。这也是我做完方案之后回头总结的第一条经验写 UI 的自定义 ShaderStencil 相关代码不是选项是标配。3. 完整 Shader 实现与参数逐段解读3.1 可直接复制的 UI 灰度 Shader 代码下面这段代码我在 Unity 2019、2020、2021、2022 系列上都跑过兼容内置渲染管线和 URP只要不是 HDRP 或者 Shader Graph 那套Basic 项目拿过去改个名字就能用。Shader Custom/UIGray { Properties { [PerRendererData] _MainTex (Sprite Texture, 2D) white {} _Color (Tint, Color) (1,1,1,1) _GrayAmount (灰度强度, Range(0,1)) 1.0 _StencilComp (Stencil Comparison, Float) 8 _Stencil (Stencil ID, Float) 0 _StencilOp (Stencil Operation, Float) 0 _StencilWriteMask (Stencil Write Mask, Float) 255 _StencilReadMask (Stencil Read Mask, Float) 255 _ColorMask (Color Mask, Float) 15 } SubShader { Tags { QueueTransparent IgnoreProjectorTrue RenderTypeTransparent PreviewTypePlane CanUseSpriteAtlasTrue } Stencil { Ref [_Stencil] Comp [_StencilComp] Pass [_StencilOp] ReadMask [_StencilReadMask] WriteMask [_StencilWriteMask] } Cull Off Lighting Off ZWrite Off ZTest [unity_GUIZTestMode] Blend SrcAlpha OneMinusSrcAlpha ColorMask [_ColorMask] Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma target 2.0 #include UnityCG.cginc #include UnityUI.cginc struct appdata_t { float4 vertex : POSITION; float4 color : COLOR; float2 texcoord : TEXCOORD0; }; struct v2f { float4 vertex : SV_POSITION; fixed4 color : COLOR; float2 texcoord : TEXCOORD0; float4 worldPosition : TEXCOORD1; }; sampler2D _MainTex; fixed4 _Color; fixed _GrayAmount; float4 _ClipRect; v2f vert(appdata_t IN) { v2f OUT; OUT.worldPosition IN.vertex; OUT.vertex UnityObjectToClipPos(IN.vertex); OUT.texcoord IN.texcoord; #ifdef UNITY_HALF_TEXEL_OFFSET OUT.vertex.xy (_ScreenParams.zw-1.0) * float2(-1,1); #endif OUT.color IN.color * _Color; return OUT; } fixed4 frag(v2f IN) : SV_Target { fixed4 col tex2D(_MainTex, IN.texcoord) * IN.color; #ifdef UNITY_UI_ALPHACLIP clip (col.a - 0.001); #endif // 灰度计算 float luma dot(col.rgb, fixed3(0.299, 0.587, 0.114)); fixed3 grayColor fixed3(luma, luma, luma); #ifdef UNITY_UI_CLIP_RECT col.a * UnityGet2DClipping(IN.worldPosition.xy, _ClipRect); #endif // 按灰度强度混合彩色与灰色 col.rgb lerp(col.rgb, grayColor, _GrayAmount); return col; } ENDCG } } }这段代码看起来长但每一块都有它的作用。Properties 里我加了[PerRendererData]给_MainTex这个是 Unity 属性面板的特性标签加上它之后同一个材质可以被多个 Image 共用纹理由 Sprite 渲染器单独传入这样不会因为纹理不同而把材质拆成多份对合批非常友好。3.2 顶点着色器与片元着色器的关键设计顶点着色器里UnityObjectToClipPos是把顶点从模型空间转到裁剪空间的标准函数。对于 UI 元素这个模型空间是相对于 Canvas 的Canvas 缩放由 Unity 内部自动处理好。比较关键的是UNITY_HALF_TEXEL_OFFSET这段这是老式 DirectX 平台上为了避免纹理采样偏移造成的像素抖动而加的半像素偏移保留它不影响其他平台属于“有备无患”型代码。片元着色器是核心。先采样纹理并乘上顶点色这个顶点色实际上包含了 Image 的 Color 属性设置和 Canvas 的顶点色批量染色如果在这里漏乘IN.color那你在 Inspector 里把 Image 的 Color 调成半透明会完全没反应。然后是UNITY_UI_ALPHACLIP分支这个宏由MaterialPropertyBlock或者UI/Default的变体控制主要是配合某些需要裁剪非常严格的场景比如文字的镂空效果。没有特殊需求的话这个分支基本不编译不消耗性能。灰度计算的UNITY_UI_CLIP_RECT分支就是前面提到的 RectMask2D 支持。当 RectMask2D 激活时Unity 会往材质里传_ClipRect和启用这个宏UnityGet2DClipping会返回一个 0 到 1 的可见度乘到 Alpha 上就能实现矩形裁剪。注意这里裁剪结果乘的是col.a而不是col.rgb这样才能让像素真正半透明消失而不是变成黑色。最后一行col.rgb lerp(col.rgb, grayColor, _GrayAmount);是整个 Shader 的核心操作_GrayAmount为 0 时显示原图为 1 时完全灰度中间值就是部分灰度。这个 lerp 意味着你可以在 C# 脚本里用 DOTween 或者协程去驱动它从 0 到 1做出非常顺滑的“彩色褪去”的动画效果。3.3 UI 界面卡顿问题排查Shader 是否可能是元凶有朋友问过我换了灰度 Shader 之后 UI 变卡是不是 Shader 写得太重了其实绝大多数情况下不是 Shader 太重而是材质实例太多打断合批。Unity 的 UGUI 合批机制要求相邻的 UI 元素尽量使用同一个材质。当你用代码给一个 Image 动态赋值了一个新材质这个 Image 和周围其他使用默认材质的 Image 就没法合批了只能单独一个 Draw Call。如果列表里同时有 20 个头像置灰那就是多出 20 个 Draw Call卡是必然的。Shader 本身的指令数极低一个 dot 加一个 lerp对 GPU 来说几乎可以忽略不计。真正影响帧率的永远是 Draw Call 数量和材质切换频率。这也是为什么我在后面的封装方案里建议如果不是频繁切换尽量让置灰的节点聚合在一起或者用预处理材质池而不是每创建一个 Image 就 new 一个材质。3.4 颜色权重与视觉终端的微调经验灰度公式的权重是死的但不同项目的 UI 风格不一样用同一套权重做出来的效果也会给人不同的感觉。我做了几个项目的灰度效果之后有一个体会偏明亮风格的 UI灰度之后往往会显得“灰蒙蒙一片”缺乏层次感而暗黑风格的 UI灰度之后观感反而更统一因为整张 UI 本身的饱和度就不高。所以我在自定义 Shader 的时候特意保留了一个_GrayAmount区间而不是只做 0/1 切换。实际项目里我们用的数值往往是 0.95而不是 1.0这样可以在完全灰度的前提下保留非常微弱的一点颜色倾向视觉上比死灰更有“呼吸感”。还有的项目会在 Shader 里额外加一个_GraySaturation参数控制灰度后颜色偏移的冷暖这些都可以在基础版上自行扩展。4. 工程接入与 C# 组件封装从能用变好用4.1 在 UGUI 上正确挂载灰度材质Shader 写好后要真正用到 UGUI 上有几个容易弄混的步骤。最直接的用法在 Project 窗口右键 Create MaterialShader 选择Custom/UIGray然后把材质拖到 Image 组件的 Material 槽里。这样是最快的验证方式但它的缺陷在于一张图片变灰了另一张图片也变灰但它们全都强依赖同一个材质。如果你通过脚本修改其中一个 Image 的_GrayAmount其他所有用这个材质的 Image 都会跟着变。所以这种用法只适合“全局统一置灰”。更合理的用法是Shader 保持原样在代码里动态创建材质实例每次都new Material(shader)然后再赋值给对应 Image。这样每个 Image 有独立的_GrayAmount互不干扰。但要注意材质的生命周期用完必须销毁否则会泄漏。还有一种是完全不用材质直接在 Image 的材质属性里设置 Shader 参数的假象。实际上 UGUI 的 Image 没有这种直接通道你最终还是得操作 material 实例。所以我在组件封装里会把“创建材质实例—赋值—销毁”这个生命周期管好省得业务代码到处 new Material 又忘了清理。4.2 封装一个可复用的灰度控制组件直接给业务方贴 Shader 代码不负责还得给一套好用的 C# 脚本。我封装组件时考虑了三个核心需求随意切换灰度/彩色、支持渐变过渡、不产生材质泄漏。下面的 C# 代码是一个简化但完整可用的版本using UnityEngine; using UnityEngine.UI; using System.Collections; [RequireComponent(typeof(Graphic))] public class GrayableGraphic : MonoBehaviour { [SerializeField, Range(0f, 1f)] private float grayAmount 0f; private Graphic graphic; private Material instanceMat; private bool hasInstanced; private Coroutine transitionCo; public float GrayAmount { get { return grayAmount; } set { grayAmount Mathf.Clamp01(value); if (hasInstanced instanceMat ! null) { instanceMat.SetFloat(_GrayAmount, grayAmount); } } } private void Awake() { graphic GetComponentGraphic(); EnsureMatInstance(); } private void EnsureMatInstance() { if (hasInstanced) return; // 注意不要直接改 graphic.material它会自动实例化并产生隐藏的材质 // 推荐直接赋值 materialUGUI 内部会帮我们处理好实例 instanceMat new Material(Shader.Find(Custom/UIGray)); graphic.material instanceMat; instanceMat.SetFloat(_GrayAmount, grayAmount); hasInstanced true; } private void OnDestroy() { if (instanceMat ! null) { Destroy(instanceMat); } } public void SetGrayImmediate(bool gray) { if (transitionCo ! null) { StopCoroutine(transitionCo); transitionCo null; } GrayAmount gray ? 1f : 0f; } public void SetGraySmooth(bool gray, float duration 0.2f) { if (transitionCo ! null) StopCoroutine(transitionCo); transitionCo StartCoroutine(TransitionTo(gray ? 1f : 0f, duration)); } private IEnumerator TransitionTo(float target, float duration) { float start grayAmount; float elapsed 0f; while (elapsed duration) { elapsed Time.deltaTime; float t Mathf.Clamp01(elapsed / duration); GrayAmount Mathf.Lerp(start, target, t); yield return null; } GrayAmount target; transitionCo null; } }这个组件的亮点在EnsureMatInstance里。很多教程直接让你写graphic.material new Material(...)但Graphic.material的 setter 本身就有实例化行为它会在设置一个新的外部材质时自动创建一份材质实例然后你手里那个new Material又变成了一份“孤儿材质”白白泄漏。正确做法是先new Material再赋给graphic.materialUGUI 内部会把它作为实例材质接收并且我们持有引用可以在 OnDestroy 时销毁。4.3 列表复用、对象池与材质实例收敛在列表页里频繁创建和销毁置灰的 Item是材质泄漏的高发地。因为如果你的列表用的是对象池每个 Item 在进出池时都会执行 Awake 和 OnDestroy那么材质实例也会跟着反复创建销毁GC 压力大不说还有可能因为漏了销毁而越积越多。我的经验是如果你的项目重度使用对象池就不要让 Item 自己管理材质而是做一个“灰度材质池”。比如 Manager 里预创建两个材质一个灰度值为 0一个灰度值为 1在 Item 进池时直接替换引用出池时根据状态分配对应的池化材质。如果列表里的元素大部分是离线/锁定这种二态需求压根不需要每个 Item 有独立材质池化材质能省下大量内存和 GC 压力。如果你的需求是“列表里每个 Item 的灰度值都不同”那要想别的招。比如把灰度值写进顶点色或者 UV3 通道里通过 Mesh 数据传给 Shader这样每个 Image 用同一个材质但每个顶点有独立的灰度值既能保证合批又能实现差异化控制。这个方案我还在测试阶段原理上完全可行适合进阶的 UI 优化有兴趣的同学可以试试。4.4 文字变灰TextMeshPro 的灰度思路很多教程只讲 Image 灰度化没人提文字。但实际项目里“按钮置灰”往往是图片和文字一起变灰如果只灰了图片没灰文字交互状态看起来就会很别扭。Unity 的 UGUI Text旧版用的 ShaderUI/Default已经包含颜色属性直接通过Text.color调成灰色就行简单粗暴。但 TextMeshPro 不一样TMP 的 Shader 是特定的TextMeshPro/Distance Field系列它有自己的顶点色逻辑直接调color只能改变整体色调做不到“只灰文字本身”那么精细。TMP 的灰度处理最佳方案是在 TMP 的材质里修改Face Color的亮度或者写一个自定义 TMP Shader在片元着色器里对faceColor.rgb套用同样的灰度公式。如果你的项目全部用 TMP建议直接改 TMP 的 Shader 变体把_GrayAmount加进去然后通过TMP_Text.fontMaterial.SetFloat控制。TMP 的材质实例管理本身也比较麻烦注意用fontMaterial而不是fontSharedMaterial否则牵一发动全身。5. 性能分析与优化灰度如何做到不拖后腿5.1 Draw Call 与 UGUI 合批机制分析先给没做过的同学补个背景UGUI 的渲染本质上是在 Canvas 下面用 Mesh 把多个 Sprite 拼在一起多个 Image 共用同一个材质时它们会被合并成一个 Mesh 一起提交这就是所谓的合批。合批能大幅减少 CPU 和 GPU 的提交开销是 UI 性能的命根子。一旦你给其中一个 Image 换了材质它就没法和周围邻居合批只能单独提交一次 Draw Call。如果是一个列表页每个 Item 都置灰且各用各的材质那整个列表的合批基本报废。这也是灰度功能上线后最容易出现的性能问题。要避免这个问题得从两个方向下手。方向一是结构上控制让同一个 Canvas 下的置灰图标尽量聚在一起比如一个按钮内部的背景图和文字可能要灰度它们俩本身已经材质不同一个是 Image Shader一个是 TMP Shader本来就不合批而按钮和按钮之间如果做的灰度一致尽量共享同一份材质。方向二是材质使用上控制如果你只是想要“全部置灰”这个二态不要给每个节点 new 材质用共享材质。5.2 移动端 GPU 与低端机的适配建议灰度化的 Shader 虽然简单但在移动端依然有几个隐藏的坑。第一个坑是half、fixed精度问题。我在代码里用的是fixed4、fixed3在高通和 Mali 的 GPU 上一般没问题但有些老旧的 Mali 驱动在fixed精度下颜色会出现明显色阶断层特别是灰度渐变的时候灰块会一截一截地跳。如果你在真机上发现灰度过渡不均匀把fixed换成half或者float再试试效果立竿见影。第二个坑是纹理格式。UI 图片如果用的是压缩纹理ASTC、ETC2采样出来的颜色和原始 PNG 有细微差异灰完之后更容易看出色块。对于需要高保真灰度的 UI 元素建议纹理类型保持 TrueColor或者使用质量更高的 ASTC 配置。第三个坑是 fill rate。UV 面积特别大的全屏图片如果置灰片元着色器的压力会比平时大一点但这种 shader 本身很简单哪怕全屏也只有几十条指令具体到帧率影响基本可以忽略。真正吃带宽的反而是 UI 上叠了很多半透明大图这种问题靠 Shader 优化救不回来得调整 UI 结构。5.3 灰度切换动画的平滑实现技巧灰度切换动画做得好不好差距很大。直接用GrayAmount从 0 到 1 线性变化视觉上会感觉“先变灰很慢后面一下变死”。这是因为人眼对亮度变化的感知不是线性的灰度在 0.2 以下时变化很不明显在 0.8 以上时又突然变得很明显。解决方式是用非线性插值。我给过渡动画加了缓动函数float t Mathf.Clamp01(elapsed / duration); t t * t * (3f - 2f * t); // SmoothStep 缓动 GrayAmount Mathf.Lerp(start, target, t);用 SmoothStep 之后过度更均匀视觉上“彩色逐渐褪去”的感觉更自然。如果你希望动画更有弹性可以尝试Mathf.SmoothDamp或者 DOTween 里的 Ease.InOutQuad具体效果根据项目风格自定。还有一个细节如果过度过程中 Image 是半透明的灰度值和 Alpha 的叠加顺序会影响结果。在我的 Shader 里先算灰度再和原色 lerp最后保留原有 Alpha这样灰度不会改变透明度符合直觉。如果你先乘了 Alpha 再算灰度会导致半透明区域出现灰色边缘晕染。这个顺序的坑我踩过一次改了位置之后效果马上正常。5.4 用 Shader 变体与关键字控制编译体积Unity 会把 Shader 的所有变体都编译进包体如果一份 UI Shader 同时支持普通绘制、灰度、遮罩裁剪、RectMask2D、AlphaClip那它的变体数量会爆炸式增长。以我的代码为例UNITY_UI_CLIP_RECT和UNITY_UI_ALPHACLIP是多选的理论组合就有 4 种实际还会叠加平台的 shader model整体体积不小。如果你对包体大小敏感可以把灰度 Shader 拆成两个一个只处理纯灰度不支持 Mask、RectMask、动态裁剪用于你把灰度写成固定贴图通道的场景另一个是完整版。或者用#pragma shader_feature_local UNITY_UI_CLIP_RECT只在对应关键字被实际使用时才编译变体不用的变体自动剔除能省一截体积。5.5 配合 Canvas 层级做 UI 卡顿专项优化开头热搜词里有“ui界面卡顿”很多人以为卡顿就是 Shader 太重用多了其实 UI 卡顿要分帧率卡和 GC 卡。帧率卡一般是 Draw Call 和 overdraw 导致的GC 卡则是因为频繁创建材质、字符串拼接、装箱拆箱等。灰度方案最容易触发 GC 卡的地方就是material.SetFloat。如果你在 Update 里每帧给 100 个 Image 调用SetFloat(_GrayAmount, value)那每帧都有 100 次材质属性设置C# 到 native 的调用开销加上字符串哈希查找累计起来足够让低端机掉帧。优化手段是只在灰度值变化超过阈值时才 SetFloat比如 0.01或者把同一 Canvas 下的同材质批量更新比如先遍历收集再统一设置。另一个 GC 点是协程。每个 Item 都开协程的话GC 压力很大。如果列表很多建议用 DOTween 统一管理或者直接用Mathf.MoveTowards放在 Update 里驱动避免协程对象分配。我自己在头像列表里做灰度渐入渐出就是用 DOTween 的DOTween.To加回调控制得很干净。6. 踩坑实录UI 灰度化常见问题与排查速查6.1 Mask 失效灰色方块穿透遮罩现象一个带 Mask 的 ScrollView 列表Item 里的头像置灰后灰色的方块盖到相邻 Item 上遮罩完全没起作用。原因自定义 Shader 里没有 Stencil 代码。UGUI 的 Mask 组件往 Stencil Buffer 写了模板值被遮罩物体的 Shader 如果不读取并比对模板值就无法被裁剪。UI/Default自带的 Shader 里有完整的 Stencil 块自定义 Shader 往往漏掉。解决把UI/Default中的 Stencil 块完整复制进你的灰度 ShaderStencil 的 Ref、Comp、Pass 等参数保持跟 UGUI 默认值一致即可。我的完整代码里已经内置直接使用不会出这个问题。6.2 RectMask2D 裁剪失效或边缘硬边现象使用 RectMask2D 的列表置灰的图片超出列表范围的部分没有隐藏或者边缘出现锯齿状硬边。原因RectMask2D 的裁剪依赖 Shader 里的UNITY_UI_CLIP_RECT宏和_ClipRect参数。如果 Shader 里没写这段代码RectMask2D 就使不上劲。另一个常见原因是_ClipRect的坐标计算写在顶点着色器之前但片元着色器里没有对应处理导致裁剪区间不对。解决确保 Shader 引入了UnityUI.cginc并在片元着色器里加上#ifdef UNITY_UI_CLIP_RECT分支用UnityGet2DClipping计算并乘到col.a上。我的代码里已经包含。如果边缘还是硬检查 Image 的 Texture Type 是否设置为 Sprite以及是否有过度压缩导致的 Alpha 精度丢失。6.3 半透明图片灰度后发白发灰现象一个带半透明描边或阴影的图标置灰之后边缘出现一圈发白发灰的“光晕”怎么看怎么脏。原因半透明区域的颜色值本身并不是“半透明彩色平均”比如一个红色半透明区域的 RGB 可能接近 (1, 0, 0)Alpha 是 0.3。如果直接对这个 RGB 算灰度会把 (1,0,0) 灰成 0.3 左右的灰色再叠加 Alpha 0.3最后颜色很浅很白。这其实是公式顺序的问题。解决先算灰度再乘 Alpha 是对的但要注意加权公式里 Alpha 对视觉的影响。更稳的做法是如果原图 Alpha 太接近 0就完全保持原图的 Alpha灰色只应用于彩色部分。我的代码里col.rgb lerp(col.rgb, grayColor, _GrayAmount)发生在col.a * UnityGet2DClipping之后但 grayColor 的计算用的是原始col.rgb不会因为这个而变成透明。如果问题持续检查一下原图是否在制作阶段就压掉了 Alpha导致边缘本来就存在脏颜色。6.4 Canvas 下的 Image 排序被打乱现象给一个置灰后的 Image 增加了透明度动画它会突然跳到其他 UI 元素上面或者显示顺序跟 Hierarchy 不一致。原因UGUI 的渲染顺序和 Hierarchy 结构相关同时受 Canvas 的 Sorting Order 和材质 V 值影响。当你给 Image 换了材质材质在某些情况下会改变渲染队列或者影响 Batch 分组导致排序异常。最常见的是 Shader 里Queue设置不对比如设置成了Geometry就会在 Transparent 之后渲染视觉上“浮上来”。解决Shader 的 Tags 里Queue必须保持Transparent不要动。同时确保ZWrite Off。如果排序还是乱检查同 Canvas 下是否混用了不同 Render Mode 的 Canvas或者 Image 的 Raycast Target 阻挡了点击但显示正常——这类问题通常不是灰度 Shader 直接导致的而是 UI 结构本身有隐患。6.5 材质泄漏内存爆炸与卡顿的隐形杀手现象列表页反复滚动、反复切换灰度状态内存不降反升帧率越来越低最后 OOM。原因每次调用Image.material new Material(...)都会创建一个材质实例。如果旧的材质没有销毁Unity 并不会在你替换 material 时自动帮你清理旧材质于是泄漏堆积。还有一种隐蔽情况是Graphic.material的 getter 会返回材质实例如果你把 getter 的结果存到一个字段里又不去销毁它gg。解决按照我前面组件的写法用EnsureMatInstance管理生命周期在OnDestroy里销毁自己创建的材质。不要轻易new Material塞给Image.material更不要用graphic.material graphic.material这种写法那会实例化出一份一模一样的材质还无人引用。6.6 灰度开关频繁切换导致 Batcher 闪断现象按钮在 CD 倒计时过程中每帧都切换一次灰色/彩色Draw Call 忽高忽低帧率波动明显。原因不同灰度状态需要使用不同材质的 Shader因为_GrayAmount不同材质其实也不一样。如果你在 0 和 1 之间快速变化GPU 上的材质状态一直在切换Batch 也在不断重建帧率自然不稳。解决不要直接对_GrayAmount做高频渐进式动画而是用两张表现差异不大的状态图做交叉淡化或者直接把灰度值拆成离散档位比如 0 / 0.5 / 1 三档配合 DOTween 补间避免 Batcher 持续重建。如果你确实需要连续渐变建议对整块 Canvas 做后处理或者用全屏过渡效果不要逐元素去改。6.7 与 Unity 版本相关的兼容性问题现象Unity 2021 下灰度正常2022 升级后 Mask 失效或者 clipping 全错乱。原因UGUI 的内置 Shader 在不同版本有调整Stencil 参数的具体值、unity_GUIZTestMode的定义可能发生变化。如果你的 Shader 是从旧版本抄的在新版本里兼容性会有问题。解决升级版本后打开 Unity 内置的UI/DefaultShader对比 Stencil 块和ZTest [unity_GUIZTestMode]的定义按新版本格式适配。千万别偷懒不做这步灰度 Shader 的兼容性测试是 UI 升级里最容易忽视的雷。6.8 灰度之后颜色偏色伽马空间与线性空间的取舍现象同一个 Shader 在 Color Space 设为 Gamma 的项目里正常换到 Linear 项目后整体灰得发蓝或发红。原因线性空间下纹理采样后是线性颜色如果直接套用 Rec.709 权重公式结果会偏暗或偏色。正确做法是线性空间下做灰度时把颜色先转到 Gamma 空间灰完再转回来或者直接使用线性空间下的亮度公式。解决在 Shader 里增加判断比较麻烦最省事的方式是统一用UnityCG.cginc里的LinearRgbToLuminance函数它会根据当前颜色空间自动处理权重。或者简单粗暴一点在片元着色器开头#ifdef UNITY_COLORSPACE_GAMMA分一个分支gamma 空间用固定权重linear 空间用带 sRGB 转换的权重。具体实现不复杂但必须提前考虑。7. 基于灰度的延伸从置灰到滤镜与状态表现7.1 一个灰度参数演变出的 UI 状态机当你的 UI 支持灰度参数后再往深走一步就能做成一套简单的 UI 状态表现系统。比如同一个 Image 在正常、悬停、按下、禁用、倒计时五种状态下只需要调节同一个材质参数和颜色就可以完成视觉区分状态灰度值颜色处理适用场景正常0原色默认显示悬停0.15轻微变灰提示可点鼠标悬停按下0.3加深变灰模拟按压点击瞬间禁用1完全置灰禁止交互不可用状态倒计时0.6半灰半彩配合 CD 数字冷却未结束这套状态机的价值在于它不需要美术多出任何资源只用参数就能表达不同的交互阶段UI 的反馈层次感会明显提升。我在项目里用这个思路做了一个简单的UIStateTransition组件把交互事件PointerDown、PointerUp、PointerEnter、PointerExit和灰度/颜色/缩放组合起来一套代码覆盖了所有按钮后续改交互风格只改参数。7.2 低调的项目灰度与 UI 特效的叠加技巧灰色之后 UI 往往会显得很“死”所以很多项目会在灰度基础上叠加一层特效让“不可用”的状态看起来是有设计感的而不是单纯的没做。比如物品锁定状态可以给灰色图标加一点轻微的下沉效果Scale 0.98 灰度 1倒计时状态可以给灰色上叠加一个圆形扫光效果扫过的地方亮一下暗示“快恢复了”。这些特效完全可以用同一个 Shader 扩展在片元着色器里加一个_ScanLinePos、_ScanLineWidth参数用 UV 坐标计算扫光位置叠到颜色上。缺点是 Shader 会变复杂变体增多所以我的建议是灰度基础版和扫光特效版分开两个 Shader按需切换材质避免所有 UI 元素都背上特效的指令开销。7.3 灰度作为后效整屏暗黑还是逐元素有些项目需要做“整场战斗结束后的结算界面暗黑化”或者“新手引导聚焦时周围变灰”。这种需求有两种实现思路一是对全屏 UI 做一个后处理材质适合整个 Canvas 变暗变灰二是逐元素挂灰度组件适合局部区域变灰。多种方案混合使用时注意“全局灰 局部彩”的优先级。比如新手引导需要周围灰、目标高亮那么对背景 Canvas 做全屏灰时高亮的目标必须在另一个 Camera 或更高层级的 Canvas 上用不受灰度影响的方式渲染。这个思路其实可以推广到“灰度 高亮”的引导方案视觉结果以更自然的方式替代了常见的遮罩挖洞方案。8. 工程化落地灰度组件与现有 UI 框架的整合8.1 与 UI 管理框架的状态绑定做完整项目灰度组件不能只停留在“Image 上挂一个脚本”的阶段。我习惯把灰度和 UI 状态框架绑定在一起每个 UI 元素有一个枚举状态Normal, Disabled, CoolingDown状态变更时统一走一个接口比如IUIGrayable。这样业务代码只关心语义状态底层负责把状态翻译成灰度和颜色。public interface IUIGrayable { void SetGrayEnabled(bool enabled); void SetGrayAmount(float amount); }如果项目的 UI 已经有 MVC 或者 MVVM 框架比如 UniRx、UniTask 那套灰度参数可以做成数据驱动再由框架层统一调用SetGrayAmount。这样协调逻辑和表现逻辑分离出问题也容易排查。8.2 ANDRoid 与 iOS 实机表现差异灰度 Shader 在 Android 和 iOS 上的表现整体大方向一致但小细节有差异。Android 端碎片化严重不同 GPU 驱动对fixed精度的支持不同个别带fixed3(0.299, 0.587, 0.114)权重的 Shader 在 Mali 上会出现明显 banding。iOS 端相对稳定但老款 A9/A10 芯片对复杂的 UI 合批效果不如高通灰度元素稍微多点就有感觉。我的实测数据在一个中等复杂度的战斗结算界面20 个头像、10 个按钮全部灰度Android 端 target 30fps 低端机不掉帧iOS 端稳定 60fps。如果灰度元素超过 50 个且是同一个材质实例Android 中端机会出现轻微掉帧需要做显示对象分组。另一个经验优先用图集减少单独 Sprite 的 Draw Call避免在Update里逐帧SetFloat要批量更新。8.3 自动化测试与灰度状态检查UI 灰度上线的首周QA 最容易漏提的 bug 是“某按钮忘了置灰”或者“置灰了但还能点击”。这事靠人工点一遍太累我加了一个简单的自动化检查在OnValidate里检查组件是否挂载了GrayableGraphic如果挂载了grayAmount不能为 0除非正常运行时用Image.raycastTarget和灰度值做联动禁用状态下直接关闭 Raycast。如果你用 UI Automation 工具做回归测试也可以在测试脚本里直接读GrayableGraphic.GrayAmount断言某个按钮的灰度值是否符合预期状态。这个成本很低但对 UI 异常状态回归特别有效。8.4 这款 Shader 的版本迭代记录我把这个灰度 Shader 从最早的工作室版本迭代到最终版经历了三个阶段。首个版本只有灰度计算和普通混合上线一周就收到 Mask 穿透的反馈第二个版本加了 Stencil 和 RectMask2D 分支兼容了头像列表和滚屏第三个版本把_GrayAmount区间从 0/1 改成任意值并加入了CanUseSpriteAtlas适配图集打包。每一次迭代都对应一个真实项目里暴露的问题。这里顺便提醒一句如果你的项目用的不是内置管线而是 URPShader 写法可以兼容但你需要把CGPROGRAM改成HLSLPROGRAM并把UnityCG.cginc替换成 URP 对应的库。这个改动不难但一旦改了就得全面回归测试尤其是UnityObjectToClipPos在 URP 里的行为已经有微调。9. 写在最后的实操心得灰度化这件事表面上是写一段 Shader实际是一个“方案选型—原理理解—工程落地—性能优化—长期维护”的完整链路。我见过很多项目被灰度功能搞得焦头烂额归根结底是第一步没想清楚到底要灰度什么、要不要过渡动画、有多少个元素会同时灰度、要不要配合 Mask 和 RectMask2D。如果你现在准备在自己的项目里搞灰度我给你的建议是先花半小时梳理状态和交互再动手写 Shader。如果不能确定会不会用到 Mask直接把带 Stencil 和 RectMask2D 支持的完整版 Shader 拿过去如果确定只是静态置灰用我开头说的共享材质方案别贪图单个控制的方便后期合批被拆的时候你会的。最后分享一个我踩过几次坑之后养成的习惯每次给 UI 加自定义 Shader我都会顺手建一个“UI Shader 检查清单”包含 Stencil 完整性、Queue是否为 Transparent、ZWrite Off是否关闭、CanUseSpriteAtlas是否声明、半透明边缘是否测试过。有了这个清单后续接手的同事不会一头雾水团队里的 UI 表现也稳定得多。