【Unity3D】优化淡入淡出文字时阴影显脏问题

在手游项目里,美术同学经常希望整块 UI 做淡入淡出,于是很自然地会在根节点加一个 CanvasGroup 并动画它的 alpha

问题来了:带描边/阴影的文本Outline/ShadowUnityEngine.UI.Shadow 系列特效)在淡入淡出过程中,经常出现白字 + 深色阴影一起变透明 所带来的" """的观感。

本文给出一个工程化但足够轻量的 trick :自定义一个 CanvasGroup"替身",在统一调节整体透明度时,"文本主体的透明度"与"阴影/描边的透明度"不同步,用非线性曲线压低阴影在低透明度阶段的存在感,从而避免脏化。


1. 现象与成因

  • Unity 的 UI 文本描边/阴影本质上是额外的几层绘制Shadow 家族通过偏移 + 颜色叠加实现)。

  • 当给根节点挂 CanvasGroup,统一降低 alpha 时,正文与阴影按同一比例衰减。

  • 由于阴影通常是深色半透明,和正文同时变淡时,叠色关系在不同底色/背景下会产生"发灰发脏"的错觉(尤其是白字 + 深色阴影)。

关键点 :我们需要在"整体淡出"的语义下,让阴影的可见度衰减更快,尽量减少它在低透明区间的干扰。


2. 设计思路

  • 做一个自定义组件 替代 CanvasGroup 在"淡入淡出"这件事上的职责。

  • 初次挂载时,缓存 所有 Graphic原始 alpha ,以及所有 Shadow(包含 Outline 的父类)特效的原始 effectColor alpha

  • 每次设置"整体 alpha"时:

    • 普通 Graphicfinal = baseAlpha * groupAlpha

    • 影子/描边:final = baseEffectAlpha * f(groupAlpha),其中 f(·)更快衰减 的函数(例如 x^4

这样就能保证一条动画线 控制"整体显隐",同时视觉重点(正文)更稳定、阴影在低透明度阶段不"搅浑"。


3. 使用方式(工作流)

  1. 替换 :把原来用于"淡入淡出"的 CanvasGroup 换成本文的 CustomCanvasGroup(类名见下文)。

    注意:不建议同时保留 CanvasGroupCustomCanvasGroup 共同影响透明度,以免相互叠加导致不可预期结果。

  2. 动画/脚本驱动 :依然像往常一样对 alpha 做动画(Animator、DOTween、手写 Update 都可)。

  3. 层级变化 :当子节点发生增删/替换 导致 Graphic/Shadow 组件集合变化时,执行一次 RefreshAllComponents() 或右键点击组件上的 "Refresh Cache" (见下文 ContextMenu)。

  4. 可选 :如需调曲线,编辑 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. 注意事项与边界

  1. 与原生 CanvasGroup 的关系

    • 本组件只负责"显隐/透明度"维度;CanvasGroup输入阻断blocksRaycastsinteractable)等行为未包含。

    • 如需交互阻断,可继续在其他地方控制 GraphicRaycaster 或把 CanvasGroup 只用于交互开关,而将"透明度"交给本组件。

  2. TMP 支持

    • 该方案对 Unity UI(UGUI)的 Shadow/Outline 生效。TextMeshPro 的轮廓/阴影是材质属性 ,需要单独适配(原理一致:正文与特效分曲线衰减)。
  3. 动态层级/实例化

    • 若运行时新创建了文本切换了特效 ,请调用 RefreshAllComponents() 以重建缓存(或在你自己的 UI 基类里统一调度)。
  4. 性能

    • 每次应用只对已有缓存做一次遍历,开销很低;避免 每帧 GetComponentsInChildren

    • 大型层级频繁变化的页面,建议将"刷新缓存"的时机放到显式的生命周期点(如 OnEnable / 页面打开时)。

  5. 动画曲线

    • 默认 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 设为 AnimatorFloat 参数,做一条 0→1 的曲线动画即可。

  • 同时你可以在 Inspector 调整 shadowAlphaCurve,无需改动画。


8. 小结

  • 问题 :整块 UI 淡入淡出时,白字 + 深色阴影易在低透明区间产生"脏化"。

  • 解法 :用一个轻量的 CustomCanvasGroup,将正文阴影/描边 的透明度分曲线 衰减(默认 x^4 风格),保留"整体显隐"的使用习惯。

  • 收益:无侵入、可视化可调、稳定解决视觉脏的问题,工程接入成本极低。