在手游项目里,美术同学经常希望整块 UI 做淡入淡出,于是很自然地会在根节点加一个 CanvasGroup
并动画它的 alpha
。
问题来了:带描边/阴影的文本 (Outline
/Shadow
等 UnityEngine.UI.Shadow
系列特效)在淡入淡出过程中,经常出现白字 + 深色阴影一起变透明 所带来的"脏 ""糊"的观感。
本文给出一个工程化但足够轻量的 trick :自定义一个 CanvasGroup
"替身",在统一调节整体透明度时,"文本主体的透明度"与"阴影/描边的透明度"不同步,用非线性曲线压低阴影在低透明度阶段的存在感,从而避免脏化。
1. 现象与成因
-
Unity 的 UI 文本描边/阴影本质上是额外的几层绘制 (
Shadow
家族通过偏移 + 颜色叠加实现)。 -
当给根节点挂
CanvasGroup
,统一降低alpha
时,正文与阴影 会按同一比例衰减。 -
由于阴影通常是深色半透明,和正文同时变淡时,叠色关系在不同底色/背景下会产生"发灰发脏"的错觉(尤其是白字 + 深色阴影)。
关键点 :我们需要在"整体淡出"的语义下,让阴影的可见度衰减更快,尽量减少它在低透明区间的干扰。
2. 设计思路
-
做一个自定义组件 替代
CanvasGroup
在"淡入淡出"这件事上的职责。 -
初次挂载时,缓存 所有
Graphic
的原始 alpha ,以及所有Shadow
(包含Outline
的父类)特效的原始 effectColor alpha。 -
每次设置"整体 alpha"时:
-
普通
Graphic
:final = baseAlpha * groupAlpha
-
影子/描边:
final = baseEffectAlpha * f(groupAlpha)
,其中f(·)
是更快衰减 的函数(例如x^4
)
-
这样就能保证一条动画线 控制"整体显隐",同时视觉重点(正文)更稳定、阴影在低透明度阶段不"搅浑"。
3. 使用方式(工作流)
-
替换 :把原来用于"淡入淡出"的
CanvasGroup
换成本文的CustomCanvasGroup
(类名见下文)。注意:不建议同时保留
CanvasGroup
与CustomCanvasGroup
共同影响透明度,以免相互叠加导致不可预期结果。 -
动画/脚本驱动 :依然像往常一样对
alpha
做动画(Animator、DOTween、手写Update
都可)。 -
层级变化 :当子节点发生增删/替换 导致
Graphic
/Shadow
组件集合变化时,执行一次RefreshAllComponents()
或右键点击组件上的 "Refresh Cache" (见下文ContextMenu
)。 -
可选 :如需调曲线,编辑
shadowAlphaCurve
(默认近似x^4
),让阴影衰减更快/更慢。
4. 完整可用代码
说明
将命名空间与类名改为
Custom
前缀;增加
AnimationCurve
以便在 Inspector 调整阴影衰减;增加若干健壮性处理(空引用、层级变化时的手动刷新入口等);
依然保持 轻量 、零侵入 的特性。
cs
using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;
namespace Custom.UI
{
/// <summary>
/// 目标:在做整体淡入淡出时,让文本主体与阴影/描边按不同曲线衰减,
/// 减少白字+深色阴影的低透明区间"发脏"现象。
///
/// 用法:
/// 1) 将本组件挂在需要整体显隐控制的 UI 根节点上(替代 CanvasGroup 的显隐用途)。
/// 2) 通过脚本/动画曲线修改 alpha 字段即可(0~1)。
/// 3) 层级或子节点 Graphic/Shadow 变化后,调用 RefreshAllComponents() 或右键"Refresh Cache"。
/// </summary>
[ExecuteInEditMode]
[DisallowMultipleComponent]
[AddComponentMenu("Custom/UI/Custom Canvas Group (Split Alpha)")]
public class CustomCanvasGroup : MonoBehaviour
{
[Header("Overall Visibility")]
[Range(0f, 1f)]
[Tooltip("整体可见度(0~1),普通图形按线性衰减,阴影/描边按自定义曲线衰减。")]
public float alpha = 1f;
[Tooltip("在缓存子节点时是否包含未激活对象。层级变化后记得刷新缓存。")]
public bool includeInactive = false;
[Header("Shadow/Outline Falloff")]
[Tooltip("阴影/描边透明衰减曲线:输入为整体alpha,输出为阴影有效alpha比例。默认接近 x^4。")]
public AnimationCurve shadowAlphaCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
// 保留最近一次 alpha,避免每帧重复应用
private float _lastAppliedAlpha = -1f;
// 缓存初始透明度,避免多次读写造成漂移
private readonly Dictionary<Graphic, float> _graphicBaseAlphas = new Dictionary<Graphic, float>(64);
private readonly Dictionary<Shadow, float> _shadowBaseAlphas = new Dictionary<Shadow, float>(64);
#region Unity Callbacks
private void Awake()
{
RebuildCache();
// 默认把曲线设成"比线性更陡的 EaseIn",接近 x^4 的体感(可在 Inspector 调整)
if (shadowAlphaCurve == null || shadowAlphaCurve.length < 2)
{
shadowAlphaCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
}
}
private void OnEnable()
{
// 运行期激活时再确认一次缓存
if (_graphicBaseAlphas.Count == 0 && _shadowBaseAlphas.Count == 0)
{
RebuildCache();
}
ApplyAlphaIfChanged(force:true);
}
private void OnValidate()
{
// Inspector 拖动时也实时生效
ApplyAlphaIfChanged(force:true);
}
private void Update()
{
ApplyAlphaIfChanged();
}
#endregion
#region Public API
/// <summary>
/// 重新扫描并缓存当前节点下的全部 Graphic / Shadow 初始透明度。
/// (会将 alpha 逻辑重置为 1 并立即应用一次,避免叠乘导致的漂移)
/// </summary>
[ContextMenu("Refresh Cache")]
public void RefreshAllComponents()
{
alpha = 1f;
_lastAppliedAlpha = -1f;
ApplyAlpha(); // 先按 1 应用一次,确保回到初始态
RebuildCache();
}
/// <summary>
/// 无需外部直接调用;若你想脚本控制整体 alpha,直接赋值字段即可。
/// </summary>
public void SetAlpha(float value)
{
alpha = Mathf.Clamp01(value);
ApplyAlphaIfChanged(force:true);
}
#endregion
#region Core
private void RebuildCache()
{
_graphicBaseAlphas.Clear();
_shadowBaseAlphas.Clear();
var graphics = GetComponentsInChildren<Graphic>(includeInactive);
var shadows = GetComponentsInChildren<Shadow>(includeInactive);
foreach (var g in graphics)
{
if (g == null) continue;
// 初始 alpha 只缓存一次
if (!_graphicBaseAlphas.ContainsKey(g))
_graphicBaseAlphas.Add(g, g.color.a);
}
foreach (var s in shadows)
{
if (s == null) continue;
if (!_shadowBaseAlphas.ContainsKey(s))
_shadowBaseAlphas.Add(s, s.effectColor.a);
}
}
private void ApplyAlphaIfChanged(bool force = false)
{
if (force || !Mathf.Approximately(_lastAppliedAlpha, alpha))
{
_lastAppliedAlpha = alpha;
ApplyAlpha();
}
}
private void ApplyAlpha()
{
// 1) 普通图形:线性衰减
foreach (var kv in _graphicBaseAlphas)
{
var g = kv.Key;
if (!g) continue;
var baseA = kv.Value;
var c = g.color;
c.a = baseA * alpha;
g.color = c;
}
// 2) 阴影/描边:按曲线衰减(默认比线性更快)
float shadowFactor = EvaluateShadowFactor(alpha);
foreach (var kv in _shadowBaseAlphas)
{
var s = kv.Key;
if (!s) continue;
var baseA = kv.Value;
var c = s.effectColor;
c.a = baseA * shadowFactor;
s.effectColor = c;
}
}
private float EvaluateShadowFactor(float a)
{
// 默认曲线是 EaseIn(0->1),接近 x^4 的体感;如果用户清空了曲线,fallback 到幂函数
if (shadowAlphaCurve != null && shadowAlphaCurve.length >= 2)
{
return Mathf.Clamp01(shadowAlphaCurve.Evaluate(Mathf.Clamp01(a)));
}
// 兜底:x^4
a = Mathf.Clamp01(a);
return a * a * a * a;
}
#endregion
}
}
5. 为什么这个方法有效?
-
感知层面 :淡出初期(alpha 还较高)需要保证正文清晰 ;而一旦进入中低透明区间,阴影对视觉贡献很小,却更容易带来"灰度污染"。
用一个"更陡 "的曲线(如
x^4
或可视化调好的AnimationCurve
)让阴影更快消失,可以显著减弱脏化。 -
工程层面 :保持"整体透明度"这一统一入口 不变,便于沿用现有动画/脚本;而描边/阴影只是在内部按不同曲线处理,不破坏 UI 结构。
6. 注意事项与边界
-
与原生
CanvasGroup
的关系-
本组件只负责"显隐/透明度"维度;
CanvasGroup
的输入阻断 (blocksRaycasts
、interactable
)等行为未包含。 -
如需交互阻断,可继续在其他地方控制
GraphicRaycaster
或把CanvasGroup
只用于交互开关,而将"透明度"交给本组件。
-
-
TMP 支持
- 该方案对 Unity UI(UGUI)的
Shadow/Outline
生效。TextMeshPro 的轮廓/阴影是材质属性 ,需要单独适配(原理一致:正文与特效分曲线衰减)。
- 该方案对 Unity UI(UGUI)的
-
动态层级/实例化
- 若运行时新创建了文本 或切换了特效 ,请调用
RefreshAllComponents()
以重建缓存(或在你自己的 UI 基类里统一调度)。
- 若运行时新创建了文本 或切换了特效 ,请调用
-
性能
-
每次应用只对已有缓存做一次遍历,开销很低;避免 每帧
GetComponentsInChildren
。 -
大型层级频繁变化的页面,建议将"刷新缓存"的时机放到显式的生命周期点(如
OnEnable
/ 页面打开时)。
-
-
动画曲线
- 默认
EaseIn
即可解决大多数"白 + 深阴影"脏化场景;若你的阴影颜色/强度更特殊,直接在 Inspector 上拉一条更陡的曲线即可。
- 默认
7. 示例:脚本/动画驱动
7.1 DOTween 驱动(示意)
using DG.Tweening;
using Custom.UI;
public class PanelFade : MonoBehaviour
{
public CustomCanvasGroup group;
public void Show()
{
DOTween.Kill(group); // 清理旧 tween
group.alpha = 0f;
DOTween.To(() => group.alpha, a => group.alpha = a, 1f, 0.35f).SetEase(Ease.OutQuad);
}
public void Hide()
{
DOTween.Kill(group);
DOTween.To(() => group.alpha, a => group.alpha = a, 0f, 0.25f).SetEase(Ease.InQuad);
}
}
7.2 Animator 驱动
-
直接把
alpha
设为Animator
的 Float 参数,做一条 0→1 的曲线动画即可。 -
同时你可以在 Inspector 调整
shadowAlphaCurve
,无需改动画。
8. 小结
-
问题 :整块 UI 淡入淡出时,白字 + 深色阴影易在低透明区间产生"脏化"。
-
解法 :用一个轻量的
CustomCanvasGroup
,将正文 与阴影/描边 的透明度分曲线 衰减(默认x^4
风格),保留"整体显隐"的使用习惯。 -
收益:无侵入、可视化可调、稳定解决视觉脏的问题,工程接入成本极低。