Unity UI动效实战:用协程与 AnimationCurve 打造锻造结算流程
很多第一次做锻造、开箱、抽卡这类“结果结算”系统的开发者往往会有同样一个困惑为什么别人做的锻造结算面板弹出时能带出那种“瞬间上头的爽感”而自己做的时候最多就是让面板淡入、放个图标、显示几行文字这不是美术资源的问题。问题在于结果结算型 UI 动效本质上不是一个“动画”而是一个被完整编排的“流程状态机”。锻造结算尤其典型材料飞入、炉火燃烧、等待判定、结果面板弹出、装备图标弹跳、属性飘字、按钮出现……这些环节如果靠一个超大 Animator Controller 硬拼或者靠一堆散落的动画片段互相通信最终一定会陷入“改一个时间参数到处爆炸”的困境。本文会用一个可以跑通的最小项目把锻造结算 UI 动效从流程拆解到代码实现完整讲一遍。你会看到为什么用协程 AnimationCurve 粒子配合是最适合这类结果型 UI 动效的组合方式也会看到真正容易踩坑的地方在 Canvas 层级、粒子显示、时间缩放和流程打断这几个点上。这篇文章面向的是 Unity 开发者需要用 UGUI 做界面并且希望把动效从“纯靠美术播放一个动画”升级为“程序可控制的动效流程”。如果这是你的现状那这篇文章适合你现在读下去。1. 这篇文章真正要解决的问题先想清楚一个问题锻造结算这类动效到底难在哪如果只看表面它无非是“弹窗 图标 文字 特效”好像没什么技术含量。但真正做过的人会知道它难在四个地方。第一动的对象是 UI不是 3D 模型。3D 特效可以随便用粒子、Shader、物理模拟但 UI 特殊效果跑在 Canvas 上受 Canvas 重建、Mask、图集、Text 排版、CanvasGroup 交互状态影响。一个弹窗从 A 点移动到 B 点在 3D 场景里只是移动 Transform在 UI 里还可能触发 Layout 重排。第二流程节点多。锻造结算不是单一动画而是多段动画的串联和并联。材料消失、炉子震动、闪光特效、面板弹出、装备展示、飘字、按钮出现顺序错了视觉感受就完全不对。用 Animation Clip 做单段没问题做整条时间线很容易失控。第三结果数据是动态的。每个玩家的锻造结果不同装备图标、名称、品阶、属性加成、成功失败文案都不一样。纯动画做不到把动态数据塞进固定时间轴必须由代码在合适的时机填充。第四必须能被打断。玩家不会老老实实等完 2 秒流程。可能点击“再锻造一次”可能点击“领取”可能直接切后台。如果流程控制没有统一的停止机制就会出现“弹窗已经关了特效还在播”“按钮已经能点了遮罩还挡着”这种低级问题。所以这篇文章要解决的不是“怎么做一段好看的动画”而是“怎么用工程化的方式组织这段动画”。我会从流程拆解开始最终给出一套原生的、不依赖第三方插件的 C# 实现。2. 锻造结算动效的典型流程与设计拆解2.1 一个完整的锻造结算动效包含哪些阶段不同游戏锻造流程不完全一样但结果结算的核心阶段高度相似。以常见的“材料放入 → 炉中锻造 → 结果揭示”三段式为例可以拆成下面 6 个阶段。阶段画面内容动效类型控制者1. 材料进炉材料图标依次缩小飞向炉心位移 缩放代码补间2. 熔炼过程炉子燃烧、震动、光效序列帧 粒子粒子/帧动画3. 判定等待炉子闪烁屏幕偶尔微震序列帧 相机震动粒子/帧动画4. 揭示结果白光闪过结果面板弹出闪光 CanvasGroup 淡入代码 特效5. 装备展示图标弹跳放大品阶边框发光缩放 弹性曲线代码补间6. 收尾操作属性飘字按钮出现位移 淡入代码补间从这个表可以看出真正需要“美术做一段完整动画”的部分只有炉子燃烧和闪光特效其他部分用代码补间完全能控制。这也是本文的判断结果型 UI 动效应该以代码流编排为主以美术素材作为辅助原件。2.2 为什么要用“代码 Animation 粒子”混合实现一种常见的保守做法是美术用 Animation Clip 把整个结算过程拍成一整条动画程序在最后播放一下 Clip。这个方案在简单原型阶段可行但迭代一两次就会暴露问题策划说“结果面板提前 0.2 秒出现”“装备图标想多弹一下”“飘字想从右侧出来”美术需要重新反复调整动画曲线还要担心跟动态数据结合的部分。因为数据是代码注入的纯动画没法做到“根据数据改变出现顺序”。改用“代码补间 粒子/序列帧”之后收益很明显时间轴完全由代码控制改时间参数就是改几个 float。数据注入变得自然比如图标变化、文案变化只需要在阶段切换时赋值。特效和 UI 弹动分离序列帧播放器负责华丽代码负责节奏。打断和高频触发变得安全直接 StopAllCoroutines 即可。代价是需要写少量代码但这份代码是可复用组件而不是一次性的动画片段。3. 环境准备与项目结构3.1 版本与基础选型本文的示例基于 UGUI 方案也就是 UnityEngine.UI。推荐使用 Unity 2021.3 LTS 或更高版本。使用不同版本的 Unity 时UGUI API 基本保持兼容所以代码可以通用。关于选型有两点需要说明。第一这里不依赖 DOTween。虽然 DOTween 确实能简化补间但本文想讲清楚原理所以用 Unity 原生 API 自己实现轻量补间。理解了原理后你完全可以换成 DOTween 简化代码。第二UI 使用 UGUI 而不是 UI Toolkit因为游戏项目的 UI 特效、粒子、Mask 生态在 UGUI 上更成熟。UI Toolkit 正在发展但目前做游戏内复杂的“结算爆点”效果UGUI 仍然是最主流选择。3.2 需要的素材与资源要跑通本文的完整流程需要准备以下素材一张装备图标 Sprite用于显示锻造结果。一张品阶边框 Sprite可选用于表现高级装备。一组炉火序列帧或一个简单的火焰粒子系统。一道白光序列帧或粒子用于结果揭示时的闪光。一款中文字体用于显示装备名称和属性。如果暂时没有序列帧素材可以用纯粒子代替或者先用一张白色圆环 Sprite 模拟光效。重点是代码流程素材可以后补。3.3 场景层级结构建议锻造结算面板的层级结构直接决定你后面调 Sort Order 会不会想砸电脑。建议按“遮罩层 → 内容层 → 特效层 → 交互层”来组织。ForgeResultCanvas (Screen Space - Camera 或 Overlay) ├── DimLayer // 全屏黑色半透明用于压暗背景 ├── ResultPanel // 结果面板主体 │ ├── HeaderText // “锻造结果”标题 │ ├── EquipIcon // 装备图标 │ ├── EquipNameText // 装备名称 │ ├── AttributeText // 属性加成文本 │ └── BottomButtons // “再锻造一次”“领取”按钮 ├── EffectLayer // 特效层粒子、序列帧放这里 └── TouchBlockLayer // 流程中阻止玩家点击的透明层这里有个细节值得注意很多新手把所有 UI 都塞进同一个 Canvas然后用层级顺序控制遮挡。在结算这种多特效场景里更稳妥的做法是给结算面板单独开一个 Canvas并设置独立的 SortingOrder。比如主 UI 的 SortingOrder 是 100结算 Canvas 就是 200特效 Canvas 可以是 300。这样即使主 UI 后续有改动也不容易盖住结算特效。4. 动效核心用协程控制完整流程4.1 流程控制的思路锻造结算最容易写乱的地方就是把所有段落的代码揉在一个方法里用一堆嵌套的 if 和 Invoke 控制时间。这非常容易出错。更合适的方式是把它当成一个“阶段序列”每个阶段就是一个方法或一段迭代器逻辑用协程按顺序执行。这样做的好处有三点每段逻辑独立便于单独调整时间参数。可以随时 StopAllCoroutines 打断整个流程。阶段之间的衔接一目了然不会出现“一个动画播完忘了让另一个动画开始”的问题。4.2 协程中的阶段推进先看一个最小示例理解阶段推进的骨架。// 文件路径Assets/Scripts/Forge/ForgeResultPanel.cs using System.Collections; using UnityEngine; using UnityEngine.UI; public class ForgeResultPanel : MonoBehaviour { private void Start() { StartCoroutine(PlayForgeResultSequence()); } private IEnumerator PlayForgeResultSequence() { // 阶段1材料进炉 yield return StartCoroutine(Stage_CollectMaterial()); // 阶段2炉火燃烧 yield return StartCoroutine(Stage_Burning()); // 阶段3闪光 面板淡入 yield return StartCoroutine(Stage_ShowResult()); // 阶段4图标弹跳 yield return StartCoroutine(Stage_IconBounce()); // 阶段5飘字 开放按钮 yield return StartCoroutine(Stage_RewardInput()); } private IEnumerator Stage_CollectMaterial() { // 这里暂时用空逻辑占位后面章节替换为真实补间 yield return new WaitForSeconds(0.3f); } private IEnumerator Stage_Burning() { // 播放粒子或序列帧 yield return new WaitForSeconds(1.0f); } // 其余阶段方法省略会在第 7 节给出完整实现 }这个例子的价值在于“结构先行”。每一段逻辑被封装成一个协程方法上一段结束、下一段开始时间点都通过协程的 yield 天然控制。后面你调整某一阶段的时间不会影响其他阶段。一个小提醒yield return StartCoroutine(...)表示“等这个协程完整跑完再继续下一行”。这是常用且安全的写法比直接yield return new WaitForSeconds(...)更容易理解和维护。5. 动效核心弹性缩放与缓动曲线5.1 为什么要自己写缓动Unity 的 Animation Clip 确实支持曲线编辑但如果是代码控制数据填充用 AnimationCurve 更顺手。AnimationCurve 的核心用法是给你一个 0 到 1 的输入经过曲线映射得到一个新的 0 到 1 输出。你只需要在 Update 循环里把时间进度归一化传入曲线再把这个输出应用到目标属性上。以“装备图标弹跳”为例希望的效果是先快速放大超过 100%再回弹到 100% 附近。这个效果最简单的实现方式就是设计一条“先冲高再回落”的曲线。5.2 弹性动画实现// 文件路径Assets/Scripts/Forge/UiTweenRunner.cs using System; using System.Collections; using UnityEngine; public static class UiTweenRunner { // 让 rect 从 startScale 过渡到 endScale中间用 curve 控制节奏 public static IEnumerator ScaleRect(RectTransform rect, float startScale, float endScale, float duration, AnimationCurve curve) { float t 0f; while (t duration) { t Time.deltaTime; float p Mathf.Clamp01(t / duration); float curveValue curve.Evaluate(p); float currentScale Mathf.LerpUnclamped(startScale, endScale, curveValue); rect.localScale Vector3.one * currentScale; yield return null; } rect.localScale Vector3.one * endScale; } // 让 canvasGroup 从 fromAlpha 过渡到 toAlpha public static IEnumerator FadeCanvas(CanvasGroup group, float fromAlpha, float toAlpha, float duration, AnimationCurve curve) { float t 0f; while (t duration) { t Time.deltaTime; float p Mathf.Clamp01(t / duration); float curveValue curve.Evaluate(p); group.alpha Mathf.LerpUnclamped(fromAlpha, toAlpha, curveValue); yield return null; } group.alpha toAlpha; } }这个UiTweenRunner看起来很简单但它解决了动态数据 动效控制的核心问题你想要哪个 UI 元素在什么时间段做哪种运动就调用对应的静态方法。参数全部来自外部不需要创建一大堆 Animation Clip。调用方式也很直观// 文件路径Assets/Scripts/Forge/ForgeResultPanel.cs 中的调用示例 private IEnumerator Stage_IconBounce() { // 先快速放大到 1.15再回落到 1.0 yield return StartCoroutine(UiTweenRunner.ScaleRect( equipIconRect, 1f, 1.15f, 0.12f, bounceCurve )); yield return StartCoroutine(UiTweenRunner.ScaleRect( equipIconRect, 1.15f, 1f, 0.18f, backCurve )); }这里的bounceCurve可以是一条快速上升的曲线backCurve是一条缓出曲线。在 Inspector 中编辑这两个曲线时你就能直观感受到“调整曲线比调整动画片段快得多”。有一点要注意如果目标对象是 RectTransform修改 localScale 时要注意它是否被 LayoutGroup 控制。如果弹窗处于 HorizontalLayoutGroup 或 VerticalLayoutGroup 中localScale 的修改会被布局系统干扰。建议做动画的节点不要放在 LayoutGroup 内部。6. 序列帧特效与粒子特效的接入6.1 序列帧播放炉火燃烧、闪光这类效果美术最常给到的资源是一组序列帧。在 UI 中播放序列帧最简单的方式是写一个帧动画组件逐帧切换 Sprite。// 文件路径Assets/Scripts/Forge/UiSpriteFrameAnimator.cs using UnityEngine; using UnityEngine.UI; public class UiSpriteFrameAnimator : MonoBehaviour { public Image targetImage; public Sprite[] frames; public float frameRate 24f; private float timer; private int frameIndex; private void OnEnable() { timer 0f; frameIndex 0; } private void Update() { if (frames null || frames.Length 0 || targetImage null) { return; } timer Time.deltaTime; float interval 1f / frameRate; while (timer interval) { timer - interval; frameIndex (frameIndex 1) % frames.Length; targetImage.sprite frames[frameIndex]; } } }这个组件的使用方式很简单把序列帧 Sprite 数组拖入frames把目标 Image 拖入targetImage。播放到最后一帧后会循环。如果需要“播完一次后停止”可以加一个playOnce开关播放到最后一帧后直接禁用脚本。序列帧的帧动画播放器放在EffectLayer下面并且要保证它的父节点在 Canvas 中的 SortingOrder 高于结果面板。6.2 粒子特效在 UI 里显示的注意事项直接在一个 Screen Space - Overlay 的 Canvas 下放 ParticleSystem 是常见的坑。因为 Overlay 模式下 UI 没有世界坐标概念粒子系统默认在世界空间中渲染会出现“看不见粒子”或者“粒子出现在某个奇怪的世界坐标”的问题。处理 UI 粒子显示有几种常用方案将粒子系统的根节点放在一个使用世界坐标定位的子节点下并把它放到 UI 相机可渲染的范围内。将整个结算 Canvas 设置为 Screen Space - Camera并给粒子节点一个合适的摄像机距离。使用 RenderTexture 将粒子渲染到 RawImage 上。引入 UIParticle 这类三方组件它会通过位移补偿把粒子贴合到 UI 坐标。从稳定性和性能角度看如果项目里已经普遍使用 Screen Space - Camera那么粒子直接放进 Canvas 下的子节点并设置粒子系统为“按需要显示”再调好 SortingOrder是最省事的方案。如果整个项目都是 Overlay 模式建议用 RenderTexture 或 UIParticle而不是强行让默认粒子系统出现在 Overlay UI 中。还有一个容易被忽略的细节粒子系统的材质。UI 上的粒子通常需要半透明叠加效果默认的 Default-Particle 材质在某些 UI 层级上可能显示不正常。更稳妥的是使用 UI 常用的 Additive Shader 材质这样粒子会呈现出“发光”的叠加感更符合锻造结算的华丽需求。7. 完整示例代码ForgeResultPanel前面几节讲的是碎片化方案这一节把它们整合成一个可以运行的面板控制器。这个控制器不依赖第三方库你可以直接复制到一个新的 C# 脚本里测试。7.1 数据类与主控制器首先定义一个锻造结果数据类用来模拟服务器返回的数据。// 文件路径Assets/Scripts/Forge/ForgeResult.cs using System; using UnityEngine; [Serializable] public class ForgeResult { public bool isSuccess; public Sprite itemIcon; public string itemName; [TextArea] public string attributeText; }然后实现主控制器。它会按照“遮罩淡入 → 材料进炉 → 燃烧等待 → 白光 → 面板弹出 → 图标弹跳 → 飘字 → 按钮开放”的完整流程推进。// 文件路径Assets/Scripts/Forge/ForgeResultPanel.cs using System.Collections; using UnityEngine; using UnityEngine.UI; public class ForgeResultPanel : MonoBehaviour { [Header(CanvasGroup 控制)] public CanvasGroup panelGroup; public CanvasGroup dimGroup; [Header(核心节点)] public RectTransform resultPanelRect; public CanvasGroup effectGroup; [Header(内容展示)] public Image equipIcon; public Text itemNameText; public Text attributeText; public GameObject rewardButtons; [Header(特效)] public GameObject whiteFlashEffect; // 白光特效默认隐藏 public GameObject furnaceEffect; // 炉火特效默认隐藏 [Header(曲线)] public AnimationCurve panelInCurve AnimationCurve.EaseInOut(0f, 0f, 1f, 1f); public AnimationCurve bounceUpCurve AnimationCurve.EaseOut(0f, 0f, 1f, 1f); public AnimationCurve bounceDownCurve AnimationCurve.EaseIn(0f, 0f, 1f, 1f); [Header(时间参数)] public float dimFadeTime 0.25f; public float materialFlyTime 0.4f; public float burningTime 1.2f; public float panelInTime 0.35f; public float iconBounceUpTime 0.12f; public float iconBounceDownTime 0.18f; public float rewardDelayTime 0.3f; private WaitForSeconds burningWait; private WaitForSeconds rewardDelay; private void Awake() { panelGroup.alpha 0f; dimGroup.alpha 0f; panelGroup.interactable false; panelGroup.blocksRaycasts false; rewardButtons.SetActive(false); if (whiteFlashEffect ! null) { whiteFlashEffect.SetActive(false); } if (furnaceEffect ! null) { furnaceEffect.SetActive(false); } burningWait new WaitForSeconds(burningTime); rewardDelay new WaitForSeconds(rewardDelayTime); } public void PlayForgeResult(ForgeResult data) { if (data null) { return; } equipIcon.sprite data.itemIcon; itemNameText.text data.itemName; attributeText.text data.attributeText; StartCoroutine(PlaySequence(data)); } public void StopAllEffect() { StopAllCoroutines(); panelGroup.alpha 1f; panelGroup.interactable true; panelGroup.blocksRaycasts true; resultPanelRect.localScale Vector3.one; if (whiteFlashEffect ! null) { whiteFlashEffect.SetActive(false); } if (furnaceEffect ! null) { furnaceEffect.SetActive(false); } } private IEnumerator PlaySequence(ForgeResult data) { // 1. 遮罩淡入并显示面板容器 yield return StartCoroutine(FadeGroup(dimGroup, 0f, 0.7f, dimFadeTime)); panelGroup.alpha 1f; // 2. 材料进炉这里用一个简单的缩放演示“飞入炉心”的感觉 yield return StartCoroutine(ScaleRect(resultPanelRect, 0.6f, 1f, materialFlyTime, panelInCurve)); // 3. 炉火燃烧 if (furnaceEffect ! null) { furnaceEffect.SetActive(true); } yield return burningWait; if (furnaceEffect ! null) { furnaceEffect.SetActive(false); } // 4. 揭示结果白光闪过同时面板真正淡入 if (whiteFlashEffect ! null) { whiteFlashEffect.SetActive(true); } yield return StartCoroutine(FadeGroup(panelGroup, 0f, 1f, panelInTime)); if (whiteFlashEffect ! null) { yield return new WaitForSeconds(0.1f); whiteFlashEffect.SetActive(false); } // 5. 装备图标弹跳 yield return StartCoroutine(ScaleRect(equipIcon.rectTransform, 1f, 1.2f, iconBounceUpTime, bounceUpCurve)); yield return StartCoroutine(ScaleRect(equipIcon.rectTransform, 1.2f, 1f, iconBounceDownTime, bounceDownCurve)); // 6. 飘字延迟展示并开放操作按钮 attributeText.gameObject.SetActive(true); yield return rewardDelay; rewardButtons.SetActive(true); panelGroup.interactable true; panelGroup.blocksRaycasts true; effectGroup.alpha 1f; } private IEnumerator FadeGroup(CanvasGroup group, float from, float to, float duration) { float t 0f; while (t duration) { t Time.deltaTime; float p Mathf.Clamp01(t / duration); group.alpha Mathf.LerpUnclamped(from, to, p); yield return null; } group.alpha to; } private IEnumerator ScaleRect(RectTransform rect, float from, float to, float duration, AnimationCurve curve) { float t 0f; while (t duration) { t Time.deltaTime; float p Mathf.Clamp01(t / duration); float curveValue curve.Evaluate(p); float currentScale Mathf.LerpUnclamped(from, to, curveValue); rect.localScale Vector3.one * currentScale; yield return null; } rect.localScale Vector3.one * to; } }这段代码有几个关键点要说明。StopAllEffect方法本质上是流程安全阀。当玩家快速点击“再锻造一次”时旧的流程必须被强行终止否则会出现“两个流程同时跑一个协程设置 alpha 为 1另一个协程又把 alpha 拉回 0”的互踢问题。WaitForSeconds在 Awake 里预创建是减少 GC 的小优化。频繁创建WaitForSeconds会产生垃圾回收压力在移动端尤其应该避免。panelGroup.alpha 1f在遮罩淡入阶段就被设置了但面板是隐藏内容还是显示内容由子节点的初始状态和 rewardButtons 控制。这样可以保证“面板容器先存在但给玩家的感觉是结果还没揭晓”。7.2 场景挂载与运行验证在场景中创建一个空节点取名为ForgeResultCanvas挂上ForgeResultPanel脚本。随后创建子节点把对应的 Image、Text、CanvasGroup 拖到脚本的 Inspector 字段上。测试方式给一个测试按钮绑定下面的事件或者直接在某个协程里调用。// 调用入口示例 ForgeResult fakeData new ForgeResult { isSuccess true, itemName 烈焰战斧, attributeText 攻击 12\n暴击 2% }; GetComponentForgeResultPanel().PlayForgeResult(fakeData);运行后预期看到的效果顺序是全屏变暗 → 结果面板缩放出现 → 炉火播放约 1.2 秒 → 白光一闪 → 面板淡入 → 装备图标先放大到 120% 再回落 → 属性文案展示 → 底部按钮出现。如果某个阶段没有发生按顺序检查场景层级里那个节点有没有被其他节点遮挡对应的 Image/Text 是否被赋值了错误的对象canvasGroup.blocksRaycasts是否在测试时误设为 false。8. 常见问题与排查思路下面这些问题是做 UI 结算动效时高频出现的按投入产出比来说值得提前做成表格贴在脚本注释里。问题现象可能原因排查方式解决方案面板出现但按钮点不了panelGroup.interactable 或 blocksRaycasts 仍为 false在 OnGUI 或 Inspector 中查看 CanvasGroup 状态流程最后把 interactable 和 blocksRaycasts 恢复为 true粒子看不见Canvas 是 Screen Space - Overlay粒子没有对应世界坐标检查 Canvas RenderMode 和粒子根节点位置改成 Screen Space - Camera或者用 RenderTexture / UIParticle粒子把整个 UI 盖住粒子 Canvas SortingOrder 过高检查 SortingOrder 数值特效层高于结果面板即可不要高于全局弹窗动画突然停顿协程被 StopAllCoroutines 打断检查是否有其他脚本调用了 StopAllCoroutines尽量只在该面板脚本内部调用或改用独立的协程持有者对象真空版本下动画变快/变慢使用了 Time.deltaTime但全局 Time.timeScale 被修改检查是否在暂停游戏时触发了结算动画结算动画如果存在于暂停场景改用 Time.unscaledDeltaTime图标弹跳被 LayoutGroup 拉回来RectTransform 所在节点受 LayoutGroup 控制检查目标父节点是否有 Layout 组件动画节点不要放在 LayoutGroup 内部或放到独立节点重新播放时特效残留上次的粒子还没销毁序列帧还停在最后一帧检查 OnEnable 是否重置状态在 PlayForgeResult 开头重置所有特效节点状态这里重点说下 Time.deltaTime 的问题。很多游戏在进入锻造界面后会暂停战斗逻辑但如果直接把 Time.timeScale 设为 0UI 动效也会跟着停。如果希望结算动画在暂停状态下继续播放需要把协程里的Time.deltaTime全部换成Time.unscaledDeltaTime并且在方法注释里明确标注。反之如果希望结算动画跟随暂停就继续用 deltaTime。这个选择必须在项目早期定好否则后面大量 UI 动画都会受到影响。9. 最佳实践与工程建议9.1 用配置驱动时间轴现在这段代码把所有时间参数都暴露成了公共字段这已经比写死在代码里好很多。但更进一步的项目建议把所有参数收进一个 ScriptableObject 配置资产比如ForgeEffectConfig内容包括每个阶段的时长、目标数值、曲线。这样策划可以直接在资产里调整手感程序不需要反复改代码。配置化的核心收益不是“少写几行代码”而是“让动效参数具备版本管理能力”。你可以在 Git 里看到“这次手感调整只是改了一个配置资产”而不是翻遍代码找数字。9.2 特效对象池化序列帧和粒子在移动端的开销都不小。尤其粒子系统频繁实例化和销毁会引起 GC 波动和渲染状态切换。建议对“炉火”“白光”这类高频出现的特效做对象池。对象池的做法很简单场景里准备多个特效节点启动时隐藏需要时依次激活回收时按规则复用。// 文件路径Assets/Scripts/Forge/SimpleEffectPool.cs using System.Collections.Generic; using UnityEngine; public class SimpleEffectPool : MonoBehaviour { public GameObject effectPrefab; public int preloadCount 4; private QueueGameObject pool new QueueGameObject(); private void Awake() { for (int i 0; i preloadCount; i) { GameObject go Instantiate(effectPrefab, transform); go.SetActive(false); pool.Enqueue(go); } } public GameObject Spawn(Vector3 position, Quaternion rotation) { GameObject go; if (pool.Count 0) { go pool.Dequeue(); } else { go Instantiate(effectPrefab, transform); } go.transform.SetPositionAndRotation(position, rotation); go.SetActive(true); return go; } public void Despawn(GameObject go) { go.SetActive(false); pool.Enqueue(go); } }实际项目中对象池可以做得更精细比如粒子播完自动回收入池。这里给出最小示例是为了提醒你结果结算动效在生产环境中是会被高频反复触发的不做对象池后面性能报表会很难看。9.3 Canvas 层级与 SortingOrder 的统一约定一个大项目通常有多个 UI Canvas。如果没有约定就会出现“不同的弹窗谁盖谁全凭挂载顺序”的混乱局面。建议在项目里建立统一约定主界面 UISortingOrder 100普通弹窗SortingOrder 200全屏过场 / 大特效SortingOrder 300结算/抽奖等强反馈面板SortingOrder 250特效叠加层SortingOrder 280这个数字不是标准重要的是团队内统一。如果你发现某个特效就是反复被其他 UI 遮挡优先检查 SortingOrder而不是去调节点顺序。9.4 关注移动端渲染压力锻造结算这类“爆点”场景很容易堆特效尤其是在手机端。需要注意几个点粒子系统的 Max Particles 要限制单个结算流程不建议超过 300 个粒子。序列帧的贴图要压缩并打入图集逐帧切换大图是显存杀手。尽量避免全屏实时模糊。如果背景模糊是刚需优先用预烘焙模糊图或做一次 RenderTexture 降采样模糊而不是每一帧都在 UI 上做全屏后处理。结算面板最好有自己的 Canvas并禁用不必要的 Graphic Raycaster减少 Canvas 的重建范围。9.5 “再锻造一次”的高频触发边界从交互上讲玩家点击“再锻造一次”之后服务端请求需要时间。这段时间内按钮应该进入冷却状态防止玩家连点导致多个并发的结算流程。在 UI 层可以这样控制点击后用标志位isPlaying阻止重复触发动画结束后再恢复。private bool isPlaying; public void OnClickForgeAgain() { if (isPlaying) { return; } isPlaying true; // 停止当前结算动画重新开始 StopAllEffect(); StartCoroutine(WaitForRebuild()); } private IEnumerator WaitForRebuild() { yield return null; // 这里可以调用服务端请求的入口 // 等结果返回后再调用 PlayForgeResult isPlaying false; }这个“互斥锁 停止旧流程 新流程重新排队”的思路适用于所有结算类型界面。否则玩家连点 5 次你的面板可能开出 5 个白光特效同时播放。10. 总结与后续学习方向做一个锻造结算 UI 动效难点从来不在某一段动画怎么做而在整个“结果展示流程”怎么编排。用协程串起阶段、用 AnimationCurve 控制弹性、用粒子或序列帧补充视觉冲击力是目前结果型 UI 动效里工程成本最低、迭代成本也最低的组合方式。写这篇文章时我特意没有引入 DOTween也没有引入复杂的 UI 特效插件。因为一旦理解了这个流程控制方式替换第三方补间库只是语法层面的事情。核心不变动效是流程不是孤立动画。下一步如果你想深入可以沿着三个方向继续学习用 ScriptableObject 配置动效资产把所有时间、曲线、目标纬度配置化让策划自己调手感。了解 UIParticle / RenderTexture 方案解决 Overlay 模式下 UI 粒子渲染的更多细节。研究如何把本文的流程写法封装成通用“结算播放器”让抽卡、开箱、段位结算共用同一套动效框架。建议你在自己的项目里先找一个最简单的弹窗试着把流程拆成协程阶段再逐步加入特效。你会发现当动效变成可控制的流程之后调手感这件事会变得顺畅很多。这套方法值得收藏备用适合下次做任何“结果展示型”UI 时直接复用。