做UI文字渐显大多数人第一反应是“接个Tween库一行代码搞定”。但如果你在一个存量老项目里做剧情对话或者面对的是“不能随便加第三方插件”的工程约束会发现最简单、最可控的方案反而是自己写一个极简状态机直接驱动原生Text的alpha。这套方案不需要任何外部依赖不需要额外引入字体资源也不改变UI结构挂一个脚本就能跑。我要分享的就是这么一个方案用状态机控制Unity原生TextLegacy Text组件实现文字渐显。它适合没有任何Tween库、也不想为了一个显示效果去改工程依赖的老项目也适合想彻底看清“淡入淡出背后状态切换逻辑”的人。我会把状态拆解、核心代码、搭建步骤、常见坑全部讲清楚照着抄就能用。1. 先搞清楚原生Text渐显到底需要什么能力1.1 一个渐显效果牵出的三个问题很多人觉得“渐显”就是把alpha从0调到1闭着眼写个循环就行。真放到项目里它要解决的问题远不止一个补间动画那么简单。我梳理下来至少有三个第一个问题是谁来触发。文字是页面加载后自动显示还是等待某个事件、某个按钮、某个动画回调。触发源不同代码结构就完全不同。第二个问题是怎么算透明度。两帧之间的alpha增量是固定速度还是先快后慢、先慢后快的曲线效果。这部分决定了视觉是“平滑淡入”还是“生硬跳变”。第三个问题是显示完成之后干什么。是保持全亮、等待几秒后自动隐掉还是立刻进入下一句对白。这个“后续动作”才是渐显之外真正复杂的地方也是协程容易写成一团乱麻的根源。这三个问题对应到代码里就是“状态转移”。状态机之所以合适就是因为它天然把“当前在做什么”“下一个该做什么”表达得非常清楚。1.2 协程、Tween库与状态机怎么选我实际权衡过三种实现方式这个表格是我后来在项目里总结的选型标准方案优点缺点适合场景协程Coroutine代码直观、无依赖状态多时嵌套严重调试只能靠日志一次性淡入、没有打断需求的小效果第三方Tween库开发最快、曲线丰富增加包体与依赖版本冲突风险老项目接入成本高新工程、效果复杂、团队熟悉该库状态机状态清晰、易扩展、可打断需要自己写核心逻辑初期代码量稍多多段对白、循环提示、需要频繁打断的界面协程在“只淡入一次”时确实简单但一旦加入保持时间、淡出时间、点击跳过、循环播放协程里的yield语句会迅速失控。Tween库则把所有逻辑藏在链式调用里看起来简洁实际运行时到底处于哪个阶段排查问题时要靠断点看内部字段。状态机虽然在初期要多写几十行但每个阶段都是显式的出了问题一眼能定位。1.3 为什么状态机在这个场景下很合适我某个项目的剧情系统就是典型例子一句对白弹出后要渐显玩家点击后要立即跳过渐显直接全亮系统还要支持对白结束后自动渐隐并切下一句。如果只用协程这个“跳过”打断操作就需要不停标记标志位协程内部每个yield之后都得检查一次是否被打断写起来非常痛苦。状态机的思路是把整个流程切成几个离散状态待机、渐显中、保持全亮、渐隐中。每个状态内部只负责一件事情。被打断时不是去“终止某个协程”而是直接把当前状态切换成目标状态同时把起始透明度更新为当前实际值。这个设计让代码天然支持任意时刻的打断和跳转不需要额外维护一堆标志位。2. 拆解文字渐显的状态流程2.1 四个核心状态Idle、FadingIn、Active、FadingOut我先定义一套最小可用的状态机。之所以说是最小是因为文字渐显最基础的闭环就是这四个状态。Idle初始状态透明度为0什么都不做。等待外部调用播放接口。FadingIn渐显中透明度从当前值向1逼近。这是整个方案的主角。Active已经全亮保持不动。根据需要可以设置保持时间也可以无限保持等待外部指令。FadingOut渐隐中透明度从当前值向0逼近。用于文字退场或循环播放。这四个状态之间的关系是这样的Idle进入FadingInFadingIn完成进入ActiveActive保持结束后进入FadingOutFadingOut完成后如果开启循环则回到FadingIn否则回到Idle。其中任意一个状态下外部都可以强制跳转到另一个状态这就是打断能力。实际项目里这四个状态往往不够。比如对白系统还需要“等待玩家点击后再淡出”那就要在Active和FadingOut之间插入一个WaitingForInput状态。状态机的好处就是加一个状态只需要在枚举里多写一个名字再在Update的switch里加一个case完全不影响其他状态。2.2 状态转换的触发条件与参数设计状态切换不能随便切否则会出现透明度跳变。以下是我建议的状态切换规则Idle到FadingIn调用PlayFadeIn方法。FadingIn到Active透明度达到1且在误差允许范围内。Active到FadingOut保持时间结束外部主动调用PlayFadeOut或循环模式下自动触发。FadingOut到FadingIn循环打开且透明度到达0。FadingOut到Idle透明度到达0且不循环。任意状态直接打断调用PlayFadeIn或PlayFadeOut强制切换。对应这些切换规则组件需要暴露这样几个参数fadeInDuration渐显时长、fadeOutDuration渐隐时长、holdDuration全亮保持时长、loop是否循环、playOnAwake是否启动时自动播放。这些参数用Inspector暴露出来美术或策划可以直接调不用改代码。这里有一个参数设计上的细节渐变时长是“总时长”而不是“单帧增量”。用总时长计算的好处是不管当前透明度是多少打断后重新计算进度都会非常自然。比如文字已经被渐显到一半玩家突然点击跳过我要做的是立刻把透明度拉满而不是继续按原来的每帧增量等待。2.3 透明度的渐变算法线性插值与平滑阻尼渐显效果的核心就是透明度计算。这里有两种常见做法各有适用场景。第一种是线性插值加时间进度。记录进入当前状态时的时间点用当前时间减去进入时间除以总时长得到一个0到1的进度。再用这个进度去插值透明度。这个做法的好处是总时长精确可控配合 AnimationCurve 还能做各种缓动效果。缺点是需要记录startTime、startAlpha、targetAlpha几个字段。第二种是平滑阻尼算法Unity里对应的是Mathf.SmoothDamp。它不需要记录进入时间只需要在字段里存一个velocity。每帧把当前alpha向目标alpha平滑逼近效果非常丝滑缺点是总时长不那么精确更依赖感觉去调参数。我的建议是如果需求严格限定“必须在1秒内从0到1”用第一种时间进度插值如果只追求视觉舒适、不纠结精确时长用第二种更方便。下面核心实现部分我会先给出时间进度版本再附一个SmoothDamp扩展版。3. 手写一套可复用的渐显状态机3.1 状态枚举与组件字段直接上代码。这是一个完整的组件挂在任何带有原生Text组件的物体上就行。命名空间建议根据自己项目来我这里为了演示就不写了。using UnityEngine; using UnityEngine.UI; public class TextFadeStateMachine : MonoBehaviour { public enum FadeState { Idle, FadingIn, Active, FadingOut } [Header(组件引用)] public Text targetText; [Header(参数配置)] public float fadeInDuration 1f; public float fadeOutDuration 0.8f; public float holdDuration 1.5f; public bool loop false; public bool playOnAwake true; [Header(可选缓动曲线)] public AnimationCurve alphaCurve AnimationCurve.Linear(0f, 0f, 1f, 1f); private FadeState currentState FadeState.Idle; private float stateStartTime; private float startAlpha; private float targetAlpha; public FadeState CurrentState currentState; public bool IsFading currentState FadeState.FadingIn || currentState FadeState.FadingOut; }三个私有字段分别表示进入当前状态的时间点、当前状态开始时的初始透明度、当前状态要到达的目标透明度。这三个字段是打断切换不跳变的关键下面重点说。3.2 状态驱动的核心Update逻辑Update里只需要一个switch每个状态处理自己的逻辑。重点看着色和状态切换的边界。void Awake() { if (targetText null) { targetText GetComponentText(); } SetAlpha(0f); } void Start() { if (playOnAwake) { PlayFadeIn(); } } void Update() { switch (currentState) { case FadeState.Idle: break; case FadeState.FadingIn: UpdateFading(fadeInDuration, 1f); break; case FadeState.Active: if (holdDuration 0f Time.time - stateStartTime holdDuration) { if (loop) { PlayFadeOut(); } else { PlayFadeOut(); } } break; case FadeState.FadingOut: UpdateFading(fadeOutDuration, 0f); break; } } void UpdateFading(float duration, float targetAlphaValue) { if (duration 0f) { SetAlpha(targetAlphaValue); FinishFading(targetAlphaValue); return; } float t Mathf.Clamp01((Time.time - stateStartTime) / duration); float curveValue alphaCurve.Evaluate(t); float newAlpha Mathf.Lerp(startAlpha, targetAlphaValue, curveValue); SetAlpha(newAlpha); if (t 1f) { SetAlpha(targetAlphaValue); FinishFading(targetAlphaValue); } }这里有个细节值得注意Active状态下我无条件调用了PlayFadeOut而不是“如果非循环就等待”。这是因为循环模式下Active保持时间结束确实要进入FadingOut非循环模式下保持时间结束后通常也要淡出这才是完整的体验。如果你希望非循环模式下保持全亮不再动作把else分支改成不处理即可这个可以按需求调整。代码里最关键的是UpdateFading这个函数。它的通用性很强渐显、渐隐都能用只要传入不同的时长和目标值。打断切换时startAlpha会保存当前透明度所以不管从哪个状态切进来都不会产生跳变。3.3 对外接口与打断控制对外暴露几个方法供按钮、事件、动画回调调用。打断控制的精妙之处在于切换状态前记录当前实际透明度。public void PlayFadeIn() { if (targetText null) return; float currentAlpha targetText.color.a; EnterState(FadeState.FadingIn, currentAlpha, 1f); } public void PlayFadeOut() { if (targetText null) return; float currentAlpha targetText.color.a; EnterState(FadeState.FadingOut, currentAlpha, 0f); } public void Stop() { EnterState(FadeState.Idle, targetText.color.a, targetText.color.a); } public void SetAlpha(float alpha) { if (targetText null) return; Color c targetText.color; c.a Mathf.Clamp01(alpha); targetText.color c; } void EnterState(FadeState nextState, float fromAlpha, float toAlpha) { currentState nextState; stateStartTime Time.time; startAlpha fromAlpha; targetAlpha toAlpha; } void FinishFading(float endAlpha) { if (endAlpha 1f) { EnterState(FadeState.Active, endAlpha, endAlpha); } else { if (loop) { EnterState(FadeState.FadingIn, endAlpha, 1f); } else { EnterState(FadeState.Idle, endAlpha, endAlpha); } } }打断的关键就在EnterState。比如文字正在渐显到一半这时候玩家点击了跳过按钮按钮绑定的是PlayFadeOut吗实际上如果点击跳过应该直接让文字全亮。那就调用EnterState把状态切成ActivestartAlpha和targetAlpha都设成当前值透明度会被直接拉满。如果点击后想淡出则调用PlayFadeOut此时currentAlpha是当前半透明值从半透明开始淡出视觉完全连贯。3.4 一个更顺手的SmoothDamp版本如果项目对渐变时长没有那么精确的要求更想要“看起来舒服”可以试试这个SmoothDamp版本。它的代码更短不需要状态开始时间只需要保存一个速度值。public class TextFadeSmooth : MonoBehaviour { public Text targetText; public float smoothTime 0.2f; public float maxSpeed 0.5f; public bool playOnAwake true; private float currentAlpha; private float targetAlpha 0f; private float velocity 0f; void Awake() { if (targetText null) targetText GetComponentText(); currentAlpha targetText.color.a; } void Start() { if (playOnAwake) ShowText(); } void Update() { currentAlpha Mathf.SmoothDamp(currentAlpha, targetAlpha, ref velocity, smoothTime, maxSpeed); SetAlpha(currentAlpha); if (Mathf.Abs(currentAlpha - targetAlpha) 0.01f) { currentAlpha targetAlpha; SetAlpha(currentAlpha); velocity 0f; } } public void ShowText() { targetAlpha 1f; } public void HideText() { targetAlpha 0f; } public void Toggle() { targetAlpha targetAlpha 0.5f ? 0f : 1f; } void SetAlpha(float value) { Color c targetText.color; c.a value; targetText.color c; } }SmoothDamp版本最大的优点是没有状态切换概念任何时候改变targetAlpha文字都会自然过渡。适合那种“随时可能反复显隐”的场景比如弹窗提示、引导高亮文字。它的问题也明显无法精确控制时长也无法配合曲线。4. 从零搭一个文字渐显Demo4.1 场景基建Canvas、Legacy文本组件与脚本挂载先搭一个最简单的测试场景。新建场景后创建Canvas然后在Canvas下创建一个空物体改名FadeText给它添加UnityEngine.UI.Text组件。这里的Text指的是旧版UI文本组件也就是脚本里用UnityEngine.UI.Text引用的那个不是TextMeshPro。项目中如果装了TMP插件新建时默认会有两个选项一定要选Legacy的Text或者直接在AddComponent里搜索Text并确认图标是带T的旧版组件。给FadeText挂上我们写的TextFadeStateMachine脚本然后把Text组件拖到targetText字段。如果不想拖引用脚本Awake里会自动GetComponent只要脚本和Text在同一物体即可。4.2 参数配置、触发测试与进阶调试配置一组实用的参数fadeInDuration设0.8秒、fadeOutDuration设0.5秒、holdDuration设1秒、loop勾上。启动后文字会自动循环渐显渐隐。我喜欢先用循环模式测因为循环模式能直观暴露所有交接问题。运行后观察文字在淡出结束瞬间是否闪烁。如果淡出结束后立刻从头渐显但第一帧透明度突然从0跳到1再降回来说明状态切换时startAlpha没有正确记录。用我们这套代码不会出现这个问题因为循环模式下FadingOut结束时通过FinishFading进入FadingInEnterState里startAlpha被设成了当前透明度也就是0下一帧自然从0开始。想要精确调试状态切换可以在Update里临时输出currentState。不过我更建议直接在Inspector里看枚举字段的值因为状态在变化时面板上能看到实时切换非常直观。4.3 扩展实战打字机逐字渐显、循环呼吸提示、多段连续对白基础渐显能跑通后就可以在状态机框架上延伸了。我实际用过的扩展有三个都基于同一套状态机思想。第一个扩展是打字机加渐显。原生Text显示文字是一整块想实现“一个字一个字蹦出来”的效果传统做法是用字符串截取。配合状态机可以这样进入Typewriter状态后每frame累加计数截取字符串前缀同时让整体alpha同步从0渐显。等最后一个字显示完毕再进入Active状态。public void PlayTypewriter(string content, float interval, float fadeDuration) { targetText.text ; fullContent content; typeInterval interval; fadeDurationForTypewriter fadeDuration; currentCharCount 0; typeTimer 0f; EnterState(FadeState.Typing, 0f, 1f); } void UpdateTyping() { typeTimer Time.deltaTime; if (typeTimer typeInterval) { typeTimer 0f; currentCharCount; targetText.text fullContent.Substring(0, currentCharCount); SetAlpha(Mathf.Clamp01(currentCharCount / fullContent.Length)); } if (currentCharCount fullContent.Length) { EnterState(FadeState.Active, 1f, 1f); } }第二个扩展是循环呼吸提示。类似“点击继续”这种提示文字一般要求缓慢地渐显渐隐循环。把基础循环里的fadeInDuration设成1.2秒、fadeOutDuration设成1.2秒、holdDuration设成0就是一种平缓呼吸效果。还可以在AlphaCurve上拉一条两端平中间陡的曲线让淡入有节奏感。第三个扩展是多段连续对白。这时需要引入队列每段文字可以有自己的时长和保持时间。把状态机作为每个文字单独的控制单元用一个对话管理器按顺序调PlayFadeIn等上一段进入Active并保持结束后再调用下一段。因为每个文字都是独立的状态机各段之间可以用时间和事件解耦不会出现“一个协程里同时等三个yield”的混乱。5. 实战中踩过的坑与排查清单5.1 渐显永远停在全亮前一步这是最常见的问题也是很多人第一次手写渐变必踩的坑。原因很简单透明度一直用Mathf.Lerp向1逼近每帧乘以一个小于1的系数理论上永远到不了1。表面上看起来已经全亮但switch里的判断条件是“alpha等于1才切状态”于是状态永远停在FadingInActive永远进不去。解决方案有两条路。第一条是上面代码里用的进度判断法用时间进度t来驱动透明度t到达1就强制把alpha设为1并切换状态。第二条是在Lerp方案里加阈值判断比如alpha大于0.999就视为完成。但第二条路有个隐患阈值设太大会感觉提前停住设太小又可能永远到达不了。我最终推荐的是时间进度法精确可控。5.2 状态切换时透明度跳变、闪烁从FadingIn直接切到FadingOut如果淡出的起始值是进入FadingIn时的startAlpha而不是当前实际alpha就会“啪”一下从亮跳到半透明再淡出视觉非常突兀。这就是状态机设计里最容易忽略的地方状态切换的起始值必须取当前实际值。我们的EnterState方法通过PlayFadeIn和PlayFadeOut传入currentAlpha解决这个问题。任何切换发生时起始透明度都是当前显示值。另一个导致闪烁的问题是SmoothDamp版本里的velocity没有复位。从一个方向的渐变强制切到另一个方向时旧速度会延续产生几帧的过冲。解决方法是切换目标时顺手把velocity重置为0或者至少重置为上一次渐变速度的反向值。5.3 暂停、打断、生命周期问题的处理默认状态下我用的是Time.time这意味着Time.timeScale置为0时会暂停渐显。大多数UI效果确实应该暂停但有些场景要求UI动画不受影响比如暂停菜单弹出时背后对白仍然要正常播放。这时改用一个unscaledTime的计时方式把Time.time替换成Time.unscaledTime即可。打断问题在“点击跳过”场景里非常典型。玩家在渐显过程中点击了屏幕如果你只做了一个bool变量表示跳过协程里每次循环都要判断这个bool代码会很难看。状态机里就简单了直接把状态切到Active透明度拉满一行代码解决。如果玩家点击后还想先淡出再继续也一样切到FadingOut即可。生命周期方面要注意的是如果物体在渐显过程中被SetActive(false)状态机停在了FadingIn的某个中间进度。重新SetActive(true)后stateStartTime还是旧值Update会直接算出t大于1瞬间跳到全亮。如果需求是重新激活后重新渐显需要在OnEnable里重新调用PlayFadeIn。如果需求是恢复渐显那可以保留状态但要把stateStartTime刷新为当前时间进度从当前透明度继续。5.4 原生Text的常见使用注意用原生Text组件做渐显还有几个容易被忽视的小问题。第一个是RaycastTarget勾选状态。默认创建的Text会勾上RaycastTarget这意味着文字区域会拦截点击事件。如果你把渐显文字放在按钮上层按钮会点不到。文字不需交互时建议取消勾选这也可以稍微减少UI重建的开销。第二个是字体缺失问题。原生Text依赖项目中配置的动态字体或默认UI字体。如果默认字体不支持目标文字语言显示出来会是一排方框再好的渐显效果也白搭。检查一下Font属性确保指向了支持中文或目标语言的字体。第三个是透明度的赋值方式。不要直接改targetText.material的color而是改targetText.color。原生Text默认使用内置UI材质直接改材质颜色会污染材质实例而改Text.color是UI系统标准做法还会参与Canvas的批次合并。写在最后这套状态机方案后来被我用到好几个项目的对白、提示、剧情演出里稳定跑了两三年。我个人最大的体会是状态机的价值不在“炫技”而是把一个看似简单的效果拆成了可以随时被打断、随时被扩展的独立单元。渐显只是其中一个状态之后你往里加任何状态都很自然。最后分享一个小技巧每次切换状态时不管目标状态是什么把startAlpha都更新为当前实际透明度。就这一行代码能帮你避免掉九成以上的渐变跳变问题。这个习惯我带到了所有动画相关的代码里。如果你的项目还在为几十行渐显代码纠结引入什么库不妨先试试把这个脚本拖进去跑一遍循环模式你会直观感受到状态机带来的掌控感。