游戏UI灰度化全攻略:Shader原理、UGUI实现与性能优化
2026/9/18 19:47:31 网站建设 项目流程

搞游戏 UI 的同学,大概率都遇到过这种需求:角色头像在组队离线时置灰、按钮在 CD 中置灰、商店物品未解锁置灰……灰度化几乎是所有游戏 UI 的必备功能。网上教程不少,但大多只丢一个 Shader 代码,讲不清原理,直接拿去用还会踩 Mask 失效、材质实例泄漏、合批被打断这些坑。

这篇博文不玩虚的,直接从需求场景讲起,把灰度化的公式原理、UGUI Shader 的关键特性、完整代码、C# 封装、性能优化到排坑实录全部过一遍。我自己在项目里用这套方案做了头像、按钮、道具图标三类灰度化,跑了两年的线上版本,也经历过 UI 卡顿、Mask 失效、半透明图片灰完发白这些经典问题。跟着这篇文章走一遍,你既能拿到能直接用的代码,也能明白每一行 Shader 和每一个 C# 方法背后的道理。

1. 灰度化方案选型:为什么最终选 Shader

1.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 的自定义 Shader,Stencil 相关代码不是选项,是标配。

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 { "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent" "PreviewType"="Plane" "CanUseSpriteAtlas"="True" } 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 > Material,Shader 选择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 = GetComponent<Graphic>(); EnsureMatInstance(); } private void EnsureMatInstance() { if (hasInstanced) return; // 注意:不要直接改 graphic.material,它会自动实例化并产生隐藏的材质 // 推荐直接赋值 material,UGUI 内部会帮我们处理好实例 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.material,UGUI 内部会把它作为实例材质接收,并且我们持有引用可以在 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 虽然简单,但在移动端依然有几个隐藏的坑。

第一个坑是halffixed精度问题。我在代码里用的是fixed4fixed3,在高通和 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_RECTUNITY_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 块完整复制进你的灰度 Shader,Stencil 的 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,适配图集打包。每一次迭代都对应一个真实项目里暴露的问题。

这里顺便提醒一句:如果你的项目用的不是内置管线而是 URP,Shader 写法可以兼容,但你需要把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 表现也稳定得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询