
1. 项目概述为什么我们需要精准控制材质动画在Unity游戏开发中动画不仅仅是角色的跑跳和UI的滑入滑出。很多时候我们需要对物体本身的视觉属性进行动态变化比如让一个物体逐渐淡入淡出或者让一片水面材质的高光强度周期性闪烁。这其中材质透明度Alpha的动画控制是最基础、也最常用的一种。你可能随手就用Mathf.PingPong配合Time.time写过一个简单的淡入淡出但当需求变得复杂——比如需要在某个特定时刻暂停动画、倒退回之前的某个状态或者精准地跳转到动画时间线的50%——原生方法就显得捉襟见肘代码会迅速变得难以维护。这就是Dotween这类补间动画库大显身手的地方。它不仅仅是把事情做对更是把事情做得优雅、可控且高效。本次分享的核心就是深入探讨如何利用Dotween超越简单的DOFade实现对材质透明度动画的暂停Pause、倒放Rewind与精准的时间控制Seek。这些功能在实现游戏中的解谜元素如需要反复调整透明度的机关、剧情过场精确控制UI淡出时机或复杂的视觉效果可逆的溶解效果时至关重要。如果你曾对动画状态管理感到头疼那么这篇结合完整代码的实战解析应该能为你提供一个清晰、可靠的解决方案。2. 核心思路与方案选型为什么是Dotween与MaterialPropertyBlock在动手之前我们先厘清两个关键选择为什么用Dotween以及为什么操作材质属性时推荐使用MaterialPropertyBlock。2.1 为何选择Dotween进行高级动画控制Unity自带的Animation组件和Animator控制器对于复杂的变换和状态机动画是首选但对于简单的、程序驱动的材质属性动画它们显得过于笨重。而直接使用协程或Update循环进行插值虽然灵活但缺少对动画生命周期的标准化管理暂停、停止、回调等。Dotween的优势正在于此链式语法与可读性一行代码就能定义动画的几乎所有属性时长、缓动、循环、回调意图清晰。强大的动画控制原生支持Pause()、Play()、PlayForward()、PlayBackwards()、Rewind()、Restart()以及Goto()或DOComplete()等方法完美契合我们对动画流程控制的需求。性能与复用Dotween有高效的内部对象池管理补间动画减少了GC垃圾回收压力。对于需要频繁创建和销毁的动画如大量UI元素这一点尤为重要。丰富的缓动函数提供数十种缓动类型Ease让透明度变化不仅仅是线性还可以是弹性、回弹等极大增强视觉表现力。因此对于“控制材质透明度变化过程”这个具体任务Dotween是目前Unity社区中最成熟、最便捷的方案之一。2.2 为何强调使用MaterialPropertyBlock这是很多开发者容易忽略的性能和规范要点。直接通过material.color或material.SetColor(“_Color”)来修改透明度会带来一个严重问题它会导致材质的实例化。// 不推荐这会在运行时创建该材质的一个新实例 renderer.material.color new Color(1,1,1,0.5f);如果你的场景中有100个使用同一材质的物体修改其中任何一个的透明度Unity都会为那个物体单独创建一份材质副本。这会导致Draw Call增加每个材质实例可能产生独立的Draw Call破坏动态合批。内存占用上升大量材质实例会消耗更多内存。打包体积变大如果这些实例化材质被打包进AssetBundle会增大包体。MaterialPropertyBlockMPB的解决方案MaterialPropertyBlock允许你直接向渲染器Renderer设置属性值而无需修改或实例化底层材质。它像是在渲染指令上“贴”了一层属性覆盖层。// 推荐使用MaterialPropertyBlock不会实例化材质 MaterialPropertyBlock mpb new MaterialPropertyBlock(); renderer.GetPropertyBlock(mpb); // 获取当前属性块如果有 mpb.SetColor(“_Color”, new Color(1,1,1,0.5f)); // 设置颜色含透明度 renderer.SetPropertyBlock(mpb); // 应用属性块这样做所有物体仍然共享同一个材质资产但各自可以拥有不同的属性值如颜色、透明度完美兼顾了表现需求与渲染性能。因此在接下来的所有操作中我们都将基于MaterialPropertyBlock来修改透明度。3. 实战构建一个可控制透明度动画的组件理论清晰后我们开始构建一个可复用的组件MaterialAlphaController。这个组件将封装所有核心功能启动动画、暂停、继续、倒放、跳转。3.1 组件结构与初始化首先我们创建C#脚本并定义必要的变量和引用。using UnityEngine; using DG.Tweening; // 引入Dotween命名空间 using System; [DisallowMultipleComponent] [RequireComponent(typeof(Renderer))] // 确保挂载对象有Renderer组件 public class MaterialAlphaController : MonoBehaviour { [Header(“材质与属性设置”)] [SerializeField] private string _colorPropertyName “_Color”; // 着色器中颜色属性名通常是“_Color”或“_BaseColor” [Header(“动画参数”)] [SerializeField] private float _startAlpha 1f; [SerializeField] private float _endAlpha 0f; [SerializeField] private float _duration 2f; [SerializeField] private Ease _easeType Ease.InOutQuad; [SerializeField] private bool _playOnStart false; // 内部引用与状态 private Renderer _targetRenderer; private MaterialPropertyBlock _mpb; private Tweener _currentTween; // Dotween动画控制器 private Color _originalColor; // 用于记录初始颜色 private void Awake() { _targetRenderer GetComponentRenderer(); _mpb new MaterialPropertyBlock(); // 初始化时获取当前材质颜色并确保我们有一个正确的初始透明度 _targetRenderer.GetPropertyBlock(_mpb); _originalColor _mpb.GetColor(_colorPropertyName); // 如果当前颜色属性未设置则使用一个默认白色但应用_startAlpha if (_originalColor default(Color)) { _originalColor new Color(1, 1, 1, _startAlpha); } else { // 保留RGB只覆盖A值到_startAlpha _originalColor.a _startAlpha; } // 将设置好的初始颜色应用回去 _mpb.SetColor(_colorPropertyName, _originalColor); _targetRenderer.SetPropertyBlock(_mpb); } private void Start() { if (_playOnStart) { PlayAnimation(); } } private void OnDestroy() { // 组件销毁时安全地销毁对应的Tween防止内存泄漏 if (_currentTween ! null _currentTween.IsActive()) { _currentTween.Kill(); } } }关键点解析RequireComponent(typeof(Renderer))这个属性确保脚本只能挂载到带有Renderer如MeshRenderer, SpriteRenderer的GameObject上避免运行时错误。_colorPropertyName这是一个字符串字段让你可以自定义着色器中颜色属性的名称。对于URP/Lit着色器通常是“_BaseColor”对于标准着色器是“_Color”。公开它提高了组件的灵活性。在Awake中初始化MPB我们创建MaterialPropertyBlock实例并立即获取渲染器当前的属性。然后我们读取或设置初始颜色并将_startAlpha应用上去。这一步确保了组件激活时物体就处于我们定义的起始透明度状态。保存Tweener引用_currentTween变量至关重要它是我们后续进行暂停、跳转等所有控制操作的句柄。OnDestroy中清理这是一个好习惯。当GameObject或组件被销毁时主动调用Kill()方法销毁关联的Dotween动画防止它试图更新一个已不存在的对象而导致错误。3.2 核心动画方法的实现接下来我们实现播放动画的方法并利用Dotween的回调机制来更新MaterialPropertyBlock。/// summary /// 播放从当前透明度到目标透明度的动画 /// /summary public void PlayAnimation(float? customDuration null) { // 如果已有动画在运行先停止它 if (_currentTween ! null _currentTween.IsActive()) { _currentTween.Kill(); } float targetAlpha _endAlpha; float animDuration customDuration ?? _duration; // 支持自定义时长 // 使用Dotween的To方法对一个浮点数透明度进行补间 // 但实际更新逻辑在OnUpdate回调中 _currentTween DOTween.To( () _originalColor.a, // 获取器返回动画起始值当前透明度 (newAlpha) // 设置器在动画每一帧被调用传入新的Alpha值 { _originalColor.a newAlpha; // 更新颜色结构体的Alpha通道 ApplyColorToPropertyBlock(); // 将新颜色应用到MaterialPropertyBlock }, targetAlpha, // 目标值 animDuration // 持续时间 ) .SetEase(_easeType) // 设置缓动类型 .SetAutoKill(false) // 非常重要动画完成后不自动销毁允许我们重复使用倒放、重新播放 .OnComplete(() Debug.Log($“{gameObject.name} 透明度动画完成。”)) // 可选完成回调 .OnKill(() _currentTween null); // 动画被销毁时清空引用 _currentTween.Play(); } /// summary /// 将当前存储的颜色(_originalColor)应用到MaterialPropertyBlock并设置给Renderer /// /summary private void ApplyColorToPropertyBlock() { _targetRenderer.GetPropertyBlock(_mpb); // 每次应用前先获取避免覆盖其他可能已设置的属性 _mpb.SetColor(_colorPropertyName, _originalColor); _targetRenderer.SetPropertyBlock(_mpb); }核心逻辑拆解DOTween.To方法这是实现自定义类型插值的核心。它接受三个关键参数Getter一个返回当前值的函数() _originalColor.a。Setter一个接收新值并执行更新的函数。这里就是我们更新颜色Alpha并应用MPB的地方。EndValue 和 Duration目标值和持续时间。在Setter中更新MPB这是动画生效的关键。每一帧Dotween计算出当前的插值Alpha通过Setter函数传给我们我们更新_originalColor.a然后调用ApplyColorToPropertyBlock方法将其同步到渲染器。SetAutoKill(false)这是实现暂停、倒放、跳转的基石。默认情况下一个Tween播放完成后会自动销毁。将其设为false后这个Tween对象会保留在内存中由Dotween管理我们可以随时再次操作它比如让它从结束状态倒放回开始状态。OnKill回调当动画被手动Kill()或组件销毁时将_currentTween引用置空防止访问已销毁的对象。3.3 实现暂停、继续与倒放控制有了保留的_currentTween对象控制就变得非常简单。/// summary /// 暂停当前动画 /// /summary public void PauseAnimation() { if (_currentTween ! null _currentTween.IsActive()) { _currentTween.Pause(); Debug.Log($“{gameObject.name} 动画已暂停。”); } } /// summary /// 继续播放当前动画从暂停处开始 /// /summary public void ResumeAnimation() { if (_currentTween ! null _currentTween.IsActive()) { _currentTween.Play(); Debug.Log($“{gameObject.name} 动画已继续播放。”); } } /// summary /// 倒放动画从当前进度反向播放至起点 /// /summary public void PlayBackwardsAnimation() { if (_currentTween ! null _currentTween.IsActive()) { // PlayBackwards会从当前进度开始反向播放 _currentTween.PlayBackwards(); } else { // 如果动画不存在或未激活则重新创建一个从_endAlpha到_startAlpha的动画 float temp _startAlpha; _startAlpha _endAlpha; _endAlpha temp; PlayAnimation(); // 播放完后交换回来以便下次正向播放 _startAlpha temp; _endAlpha _startAlpha; } } /// summary /// 立即倒回动画起点并暂停 /// /summary public void RewindAnimation() { if (_currentTween ! null _currentTween.IsActive()) { _currentTween.Rewind(); // 倒回起点 _currentTween.Pause(); // 倒回后默认暂停符合直觉 } else { // 如果动画不存在直接设置透明度为起始值 _originalColor.a _startAlpha; ApplyColorToPropertyBlock(); } }注意事项PlayBackwardsvsRewindPlayBackwards()是让动画从当前时间点开始反向播放到起点是一个动画过程。而Rewind()是立即跳转到起点没有过渡动画。根据你的游戏逻辑比如一个机关需要缓慢复位还是瞬间复位来选择。状态检查所有控制方法都首先检查_currentTween是否不为空且处于激活状态IsActive()。这是一个健壮性编程的好习惯避免空引用异常。倒放的备选方案在PlayBackwardsAnimation中如果动画不存在比如还没播放过我们采用了一种交换起止值并重新播放的策略。这是一种实现方式你也可以选择直接创建一个反向的新动画。3.4 实现精准跳转Seek与进度控制这是高级控制的核心允许我们像操作视频播放器一样将动画跳转到任意时间点。/// summary /// 跳转到动画的特定时间点单位秒 /// /summary /// param name“targetTime”目标时间点应在0到动画时长之间/param /// param name“andPlay”跳转后是否立即播放/param public void GotoTime(float targetTime, bool andPlay false) { if (_currentTween ! null _currentTween.IsActive()) { // Dotween的Goto方法接受一个时间参数 _currentTween.Goto(targetTime, andPlay); // 注意Goto会立即更新动画状态到该时间点但如果我们设置了SetAutoKill(false) // 并且动画之前可能已经完成需要手动强制更新一帧。 // 一种更稳妥的方式是使用下面基于进度的方法。 } } /// summary /// 跳转到动画的特定进度0到1之间 /// /summary /// param name“normalizedTime”归一化的时间0为起点1为终点/param public void GotoNormalizedTime(float normalizedTime) { normalizedTime Mathf.Clamp01(normalizedTime); // 确保输入在0-1之间 if (_currentTween ! null _currentTween.IsActive()) { // 计算对应的具体时间 float targetTime normalizedTime * _duration; _currentTween.Goto(targetTime, true); // 跳转并暂停true表示跳转后立即评估但播放状态不变 _currentTween.Pause(); // 跳转后我们通常希望暂停以便精确查看该帧状态 // 手动调用一次更新确保材质属性立即刷新 ForceUpdateOneFrame(); } else { // 如果动画不存在则直接计算并设置对应的透明度 float targetAlpha Mathf.Lerp(_startAlpha, _endAlpha, normalizedTime); _originalColor.a targetAlpha; ApplyColorToPropertyBlock(); } } /// summary /// 强制更新当前Tween一帧用于Goto后立即刷新显示 /// /summary private void ForceUpdateOneFrame() { if (_currentTween ! null _currentTween.IsActive()) { // 这是一个小技巧手动调用一次Tween的内部更新参数是deltaTime0表示“立即评估到当前状态” _currentTween.fullPosition _currentTween.fullPosition; // 上面的赋值操作会触发Dotween内部对当前状态的重新计算和Setter的调用。 } } /// summary /// 获取当前动画的归一化进度0-1 /// /summary public float GetCurrentNormalizedTime() { if (_currentTween ! null _currentTween.IsActive() _duration 0) { return _currentTween.position / _duration; } // 如果动画未运行则根据当前透明度反推进度近似值 float currentAlpha _originalColor.a; return Mathf.InverseLerp(_startAlpha, _endAlpha, currentAlpha); }精准跳转的难点与解决方案 直接使用Goto()方法有时会遇到一个问题当动画已经播放完成IsComplete为true时即使SetAutoKill(false)保留了Tween对象Goto()也可能不会立即触发属性的更新。这是因为Dotween认为动画已经到达终点。我们的解决方案是双重的使用Goto后手动暂停Goto(targetTime, true)的第二个参数andPlay设为true这确保了Dotween会“评估”该时间点的状态。但我们紧接着调用Pause()让动画停在那里。ForceUpdateOneFrame技巧通过给_currentTween.fullPosition属性赋其自身的值这会强制Dotween内部重新计算一次当前状态并调用我们定义的Setter从而立即更新材质属性。这是一个非常实用的“黑科技”。GetCurrentNormalizedTime方法则提供了读取当前进度的能力可以用于保存游戏状态比如保存一个机关的当前透明度进度或者实现进度条UI。4. 在Inspector中测试与进阶技巧为了让这个组件在编辑器中使用更方便我们可以添加一些自定义的Inspector按钮。#if UNITY_EDITOR using UnityEditor; [CustomEditor(typeof(MaterialAlphaController))] public class MaterialAlphaControllerEditor : Editor { public override void OnInspectorGUI() { DrawDefaultInspector(); // 绘制默认的序列化字段 MaterialAlphaController controller (MaterialAlphaController)target; GUILayout.Space(10); if (GUILayout.Button(“播放动画”)) { controller.PlayAnimation(); } GUILayout.BeginHorizontal(); if (GUILayout.Button(“暂停”)) { controller.PauseAnimation(); } if (GUILayout.Button(“继续”)) { controller.ResumeAnimation(); } GUILayout.EndHorizontal(); GUILayout.BeginHorizontal(); if (GUILayout.Button(“倒放”)) { controller.PlayBackwardsAnimation(); } if (GUILayout.Button(“倒回起点”)) { controller.RewindAnimation(); } GUILayout.EndHorizontal(); GUILayout.Space(5); EditorGUILayout.LabelField(“精准跳转测试”, EditorStyles.boldLabel); GUILayout.BeginHorizontal(); if (GUILayout.Button(“跳到25%”)) controller.GotoNormalizedTime(0.25f); if (GUILayout.Button(“跳到50%”)) controller.GotoNormalizedTime(0.50f); if (GUILayout.Button(“跳到75%”)) controller.GotoNormalizedTime(0.75f); GUILayout.EndHorizontal(); // 显示当前进度 float progress controller.GetCurrentNormalizedTime(); EditorGUILayout.LabelField($“当前进度: {progress:F2}”); // 可以用ProgressBar可视化 EditorGUI.ProgressBar(GUILayoutUtility.GetRect(200, 20), progress, “动画进度”); } } #endif这个自定义编辑器添加了一排按钮让你无需写测试代码就能在Unity编辑器中直接点击测试所有控制功能并且通过进度条直观看到当前动画的进度极大地提升了开发调试效率。4.1 性能优化与常见陷阱避免每帧创建新的MaterialPropertyBlock我们的代码在Awake中创建了一个_mpb实例并在整个生命周期中复用它。在ApplyColorToPropertyBlock中我们使用GetPropertyBlock来获取当前属性而不是每次都new一个新的。这是最佳实践。Shader属性名匹配确保_colorPropertyName与你的材质实际使用的着色器属性名完全一致大小写敏感。对于URP的Lit着色器主颜色属性名是“_BaseColor”。如果设置后没效果去Shader中确认属性名。处理多个Renderer如果一个GameObject下有多个Renderer例如一个复杂的角色模型上述组件只会控制它挂载的那个。如果需要控制多个可以修改组件让它遍历GetComponentsInChildrenRenderer()并为每个Renderer维护独立的MPB和Tween逻辑会复杂一些。与UI系统的融合对于UI Image或Text的透明度动画通常直接使用CanvasGroup的alpha属性或者对Image/Text的color属性进行DotweenDOFade因为它们本身不涉及材质实例化问题。但如果你需要对UI材质比如自定义Shader的UI进行控制本文的MPB方案同样适用。4.2 扩展思路控制其他材质属性掌握了透明度的控制这套模式可以轻松扩展到其他材质属性上例如溶解效果控制_Cutoff或_Dissolve属性。颜色变化控制_Color的RGB值。纹理偏移控制_MainTex_ST的偏移量。发光强度控制_EmissionColor的亮度。只需修改DOTween.To的Getter和Setter以及MaterialPropertyBlock对应的设置方法如SetFloat,SetVector即可。你可以创建一个更通用的MaterialPropertyController通过枚举来指定要控制的属性类型。5. 完整代码整合与使用示例将所有代码整合后你就得到了一个功能完整的MaterialAlphaController.cs。使用时只需将其拖到任何带有Renderer的GameObject上在Inspector中配置好起止透明度、时长和缓动类型。一个简单的使用场景 假设你有一个需要玩家交互的“隐形门”。门默认可见Alpha1。玩家触碰开关后门在2秒内淡出Alpha0。玩家可以再次触碰开关让门从当前状态可能是半透明倒放淡入。或者在解谜中玩家需要将一个滑块拖到50%的位置让门变成半透明以通过激光。这时你就可以用GotoNormalizedTime(0.5f)来精确设置。通过将动画逻辑封装在这样一个组件中并与游戏事件如触发器、UI按钮、脚本调用绑定你就能以声明式和可控的方式驱动游戏中所有基于材质透明度的动态视觉效果代码清晰且功能强大。