1. 项目概述与核心痛点
在Unity项目里集成Spine动画,SkeletonAnimation组件是绕不开的核心。很多开发者,尤其是刚接触Spine的新手,会直接把它拖到场景里,然后兴冲冲地开始写SetAnimation。但很快就会发现,动画播不出来、皮肤切换无效、事件监听不到,或者角色莫名其妙地卡在某个姿势。这些问题往往不是Spine运行时的问题,而是对SkeletonAnimation的生命周期、状态管理以及Unity的脚本执行顺序理解不够深入导致的。
这篇文章,就是一份来自一线的、血泪教训换来的避坑指南。我会从一个资深开发者的角度,拆解SkeletonAnimation从初始化、播放控制到高级状态管理的完整流程,并提供一份经过实战检验的、可直接复用的增强脚本。这份指南的目标,是让你不仅能“用上”Spine动画,更能“用好”它,避免在项目后期被各种诡异的动画Bug折磨。
2. SkeletonAnimation组件深度解析
2.1 组件初始化与数据绑定
SkeletonAnimation组件的核心是SkeletonDataAsset。很多人第一步就踩坑:在运行时动态加载Spine资源后,如何正确赋值?直接修改SkeletonData Asset字段?不,那通常只在编辑器模式下有效。
正确的做法是使用SkeletonAnimation.NewSkeletonAnimationGameObject静态方法,或者在Awake/Start中确保组件已初始化。这里有一个关键细节:SkeletonAnimation组件有一个Initialize方法,它控制着底层数据的加载时机。
using Spine.Unity; using UnityEngine; public class SpineCharacterController : MonoBehaviour { private SkeletonAnimation _skeletonAnimation; private Skeleton _skeleton; private Spine.AnimationState _animationState; void Awake() { _skeletonAnimation = GetComponent<SkeletonAnimation>(); // 关键:如果你后续需要立即访问Skeleton或AnimationState, // 必须确保初始化完成。传false表示不强制重新初始化(如果已初始化)。 _skeletonAnimation.Initialize(false); _skeleton = _skeletonAnimation.Skeleton; _animationState = _skeletonAnimation.AnimationState; // 此时,_skeleton和_animationState才是可用的 Debug.Log($"骨骼数量: {_skeleton.Bones.Count}"); } }注意:
Initialize(false)中的false参数很重要。如果传true,会强制重新初始化,这可能清空当前的动画状态和皮肤设置。除非你明确需要重置所有状态(比如切换完全不同的角色模型),否则在Awake中调用Initialize(false)是安全且推荐的做法。
2.2 动画状态机(AnimationState)的生命周期
这是最容易出问题的地方。SkeletonAnimation内部维护着一个AnimationState对象,它才是动画播放的真正控制者。它的更新流程是这样的:
- Update阶段(默认在
MonoBehaviour.Update中调用):AnimationState根据时间(Time.deltaTime * timeScale)更新所有轨道(Track)上动画的播放进度(trackTime)。 - 应用阶段:将更新后的
AnimationState应用到Skeleton上,计算所有骨骼的最终变换(Transform)。 - LateUpdate阶段:根据
Skeleton计算出的新姿势,重新生成网格(Mesh)以供渲染。
很多开发者试图在Update中直接修改骨骼位置,然后发现修改被动画覆盖了,就是因为没搞清楚这个顺序。你的修改如果发生在SkeletonAnimation自身的Update之后,那么在这一帧,动画计算已经完成,你的修改只会被应用到骨骼的“局部变换”,但最终的世界变换在LateUpdate生成网格前可能又被重新计算了(取决于你是否触发了UpdateWorldTransform)。
避坑点:如果你需要每帧基于逻辑(比如IK、跟随鼠标)修改骨骼位置,你有两个选择:
- 在
SkeletonAnimation.Update之前执行你的代码:通过[DefaultExecutionOrder(-100)]属性将你的脚本执行顺序设为比SkeletonAnimation更早。这样你的修改会在动画应用之前生效,动画会基于你修改后的骨骼状态进行叠加。 - 订阅
SkeletonAnimation的回调:SkeletonAnimation提供了几个关键的委托(Delegate),允许你在其生命周期的特定时刻插入代码:UpdateComplete:在动画更新、世界变换计算完成后触发。这是读取骨骼世界位置、旋转的最佳时机,比如让粒子效果跟随某个骨骼。UpdateLocal:在动画应用到骨骼的局部变换之后,计算世界变换之前触发。如果你想在动画基础上做局部修改(比如基于动画结果的微调),可以在这里进行。UpdateWorld:在计算完世界变换后触发。如果你在这里修改了骨骼的局部值,组件会再次计算世界变换。性能开销较大,通常用于实现自定义的物理约束。
void Start() { _skeletonAnimation.UpdateComplete += OnSkeletonUpdateComplete; } void OnSkeletonUpdateComplete(ISkeletonAnimation anim) { // 此时骨骼的世界变换已是最新,可以安全读取 Bone headBone = _skeleton.FindBone("head"); Vector3 worldHeadPos = headBone.GetWorldPosition(_skeletonAnimation.transform); // 可以用这个位置来放置特效、检测碰撞等 }3. 动画播放控制的完整脚本与避坑实践
3.1 基础播放:SetAnimation vs AddAnimation
播放动画最常用的两个方法是SetAnimation和AddAnimation。它们的区别是核心中的核心。
SetAnimation(int trackIndex, string animationName, bool loop):立即清空指定轨道上的当前动画(如果有),并开始播放新动画。如果新旧动画不同,会使用在Spine编辑器中设置的Mix Duration进行混合过渡。AddAnimation(int trackIndex, string animationName, bool loop, float delay):将动画加入队列。当前轨道的动画会继续播放,在delay秒后(如果当前动画是循环的,则在其当前循环结束后),开始播放队列中的这个动画。
最常见的坑:在Update里每帧调用SetAnimation。比如你想让人物一直播放“idle”动画,于是写了:
void Update() { _animationState.SetAnimation(0, "idle", true); }结果就是人物永远卡在“idle”的第一帧。因为每一帧都在重新开始播放“idle”,动画根本没有时间推进。正确的做法是状态驱动:只在状态改变时设置动画。
下面是一个完整的、健壮的动画控制器脚本示例,它管理了基础状态(Idle, Walk, Run, Jump)并处理了动画混合:
using Spine; using Spine.Unity; using UnityEngine; public class AdvancedSpineAnimController : MonoBehaviour { public SkeletonAnimation skeletonAnimation; // 使用SpineAttribute特性,在Inspector中显示下拉菜单,避免拼写错误 [SpineAnimation] public string idleAnimationName; [SpineAnimation] public string walkAnimationName; [SpineAnimation] public string runAnimationName; [SpineAnimation] public string jumpAnimationName; private AnimationState _spineAnimationState; private Skeleton _skeleton; // 当前动画状态 private string _currentAnimation; private int _mainTrack = 0; // 用于平滑切换的混合时间 public float mixDuration = 0.2f; void Awake() { if (skeletonAnimation == null) skeletonAnimation = GetComponent<SkeletonAnimation>(); skeletonAnimation.Initialize(false); _spineAnimationState = skeletonAnimation.AnimationState; _skeleton = skeletonAnimation.Skeleton; // 设置默认混合时间(也可以每个动画单独设置) _spineAnimationState.Data.DefaultMix = mixDuration; } /// <summary> /// 播放动画,确保不会重复设置相同的动画 /// </summary> /// <param name="animationName">动画名称</param> /// <param name="loop">是否循环</param> /// <param name="forceRestart">是否强制重新开始(即使动画相同)</param> /// <returns>返回TrackEntry,可用于监听事件</returns> public TrackEntry PlayAnimation(string animationName, bool loop, bool forceRestart = false) { if (string.IsNullOrEmpty(animationName)) return null; // 关键避坑点:避免重复设置相同动画,除非强制重启 if (!forceRestart && _currentAnimation == animationName) { // 可以在这里选择是返回当前的TrackEntry,还是什么都不做 var currentEntry = _spineAnimationState.GetCurrent(_mainTrack); return currentEntry; } _currentAnimation = animationName; TrackEntry entry = _spineAnimationState.SetAnimation(_mainTrack, animationName, loop); // 可以在这里为这个特定的TrackEntry设置自定义混合时间 // entry.MixDuration = customMix; // 监听动画完成事件(对于非循环动画很有用) entry.Complete += OnAnimationComplete; return entry; } void OnAnimationComplete(TrackEntry trackEntry) { // 非循环动画播放完毕后的处理 // 例如,跳跃动画播放完后自动切回 idle if (trackEntry.Animation.Name == jumpAnimationName && !trackEntry.Loop) { // 可以加一个延迟,或者直接切换 PlayAnimation(idleAnimationName, true); } // 记得移除监听,避免内存泄漏(如果TrackEntry被复用) trackEntry.Complete -= OnAnimationComplete; } /// <summary> /// 使用空动画进行平滑过渡(Mix Out) /// 适用于需要动画平滑停止,而不是突然消失的场景 /// </summary> public void StopAnimationSmoothly(float mixOutDuration = 0.5f) { _spineAnimationState.SetEmptyAnimation(_mainTrack, mixOutDuration); _currentAnimation = null; } /// <summary> /// 立即清空动画,角色将保持当前姿势 /// </summary> public void ClearAnimation() { _spineAnimationState.ClearTrack(_mainTrack); _currentAnimation = null; } // 示例:供其他系统调用的接口 public void SetMovementAnimation(float speed, float maxWalkSpeed) { if (speed > maxWalkSpeed) { PlayAnimation(runAnimationName, true); } else if (speed > 0.1f) { PlayAnimation(walkAnimationName, true); } else { PlayAnimation(idleAnimationName, true); } } }3.2 多轨道动画与叠加
Spine支持多轨道动画,这是实现复杂动画组合的利器。例如,轨道0播放基础行走动画,轨道1播放上半身的射击动画,轨道2播放面部表情动画。
// 在基础动画上叠加一个上半身攻击动画 TrackEntry upperBodyTrack = _spineAnimationState.SetAnimation(1, "shoot", false); // 设置轨道的权重,0.5表示上半身动画只影响50%的骨骼(需要动画师在Spine中设置好骨骼层级) upperBodyTrack.MixBlend = MixBlend.Additive; // 或者 MixBlend.First // 叠加动画通常使用Additive混合模式,这样它会在基础动画上“增加”变换,而不是覆盖。 // 播放一个面部表情动画,并让它播放完后自动清空轨道 TrackEntry faceTrack = _spineAnimationState.SetAnimation(2, "smile", false); faceTrack.MixDuration = 0.1f; // 监听完成事件,然后清空该轨道 faceTrack.Complete += (entry) => { _spineAnimationState.ClearTrack(entry.TrackIndex); };避坑点:轨道的索引号是任意的,但通常0是主轨道。高索引轨道的动画会覆盖低索引轨道对相同骨骼的影响,除非混合模式是Additive。务必在Spine编辑器中规划好骨骼的分层(使用骨骼的继承和图层顺序),并在代码中合理设置MixBlend。
3.3 皮肤切换与组合
切换皮肤是换装系统的核心。直接使用SetSkin即可,但有一个必须记住的步骤:切换皮肤后,要调用SetSlotsToSetupPose。
public bool ChangeSkin(string skinName) { if (_skeleton == null) return false; bool success = _skeleton.SetSkin(skinName); if (success) { // 关键步骤:将插槽重置为Setup Pose,否则旧皮肤的附件可能仍然可见。 _skeleton.SetSlotsToSetupPose(); // 应用更改到Skeleton _spineAnimationState.Apply(_skeleton); } else { Debug.LogWarning($"皮肤 '{skinName}' 未找到。"); } return success; }对于更复杂的换装(比如混合多个部位的皮肤),需要使用Skin组合API:
public void CombineSkins(params string[] skinNames) { var skeletonData = _skeleton.Data; var combinedSkin = new Skin("combined-skin"); foreach (var skinName in skinNames) { var skin = skeletonData.FindSkin(skinName); if (skin != null) { combinedSkin.AddSkin(skin); } } _skeleton.SetSkin(combinedSkin); _skeleton.SetSlotsToSetupPose(); _spineAnimationState.Apply(_skeleton); }性能警告:动态组合皮肤会使用多种材质,增加Draw Call。对于性能要求高的移动平台,可以考虑使用运行时重打包(GetRepackedSkin)将多个皮肤的贴图合并到一张大图里,但这会带来额外的CPU和内存开销,适合在加载时进行,不适合每帧操作。
4. 事件监听与高级交互
4.1 动画事件(Spine Event)
在Spine编辑器中,你可以在时间轴上添加事件(Event),并在代码中监听它们。这是实现音效、粒子特效、伤害判定帧的标准化方式。
监听事件有两种方式:
- 监听特定轨道条目的
Event委托:只监听某一个动画触发的事件。 - 监听
AnimationState的Event委托:监听所有动画的所有事件。
void Start() { // 方式1:监听所有事件(需要过滤) _spineAnimationState.Event += OnSpineEvent; // 方式2:监听特定动画的特定事件(更高效) TrackEntry attackEntry = _spineAnimationState.SetAnimation(0, "attack", false); attackEntry.Event += OnAttackAnimationEvent; } void OnSpineEvent(TrackEntry trackEntry, Event e) { // 过滤事件名 if (e.Data.Name == "footstep") { PlayFootstepSound(); } else if (e.Data.Name == "shoot") { SpawnBullet(); } } void OnAttackAnimationEvent(TrackEntry trackEntry, Event e) { // 这里只处理“attack”动画里的事件 if (e.Data.Name == "hit") { ApplyDamageToTarget(); } }避坑点:事件回调的执行时机在SkeletonAnimation.Update之后、LateUpdate之前。如果你在事件回调里修改了游戏状态(比如生成一个子弹对象),而这个新对象也需要在这一帧更新,要注意执行顺序问题。如果新对象的Update在SkeletonAnimation.LateUpdate之后,它可能要到下一帧才会被正确初始化。对于这种情况,可以考虑使用StartCoroutine配合yield return null将部分逻辑推迟到下一帧开始。
4.2 使用Yield指令进行协程等待
Spine-unity运行时贴心地提供了几个用于协程的Yield指令,让异步动画控制变得非常优雅。
IEnumerator PlayAttackAndWait() { TrackEntry attackTrack = _spineAnimationState.SetAnimation(0, "attack", false); // 等待攻击动画播放完成(包括循环的最后一圈) yield return new WaitForSpineAnimationComplete(attackTrack); Debug.Log("攻击动画播放完毕!"); // 自动切换回待机 PlayAnimation(idleAnimationName, true); } IEnumerator PlayAnimationSequence() { _spineAnimationState.SetAnimation(0, "start", false); // 等待一个名为“charge_complete”的用户自定义事件 yield return new WaitForSpineEvent(_spineAnimationState, "charge_complete"); _spineAnimationState.AddAnimation(0, "release", false, 0); yield return new WaitForSpineAnimationEnd(_spineAnimationState.GetCurrent(0)); _spineAnimationState.AddAnimation(0, "recover", false, 0); }5. 性能优化与常见问题排查
5.1 性能关键参数解析
在SkeletonAnimation的Inspector面板中,Advanced折叠栏下有很多参数,理解它们对性能影响巨大:
- Update When Invisible:当渲染器不可见时的更新模式。对于大量屏幕外的角色(如RPG地图上的NPC),设置为
Nothing或OnlyAnimationStatus可以节省大量CPU开销。 - Use single submesh:如果你的Skeleton只使用一个材质球,务必勾选。这能合并Draw Call。如果用了多个材质(比如角色和武器材质不同),则不能勾选。
- Immutable triangles:如果角色的附件(图片)不会在运行时动态显示/隐藏(即网格拓扑不变),务必勾选。这能避免每帧重新上传网格数据到GPU,显著提升性能。
- Fix Draw Order:只有在使用3个以上子网格且渲染顺序交错(如A-B-A)导致合批出错、渲染顺序混乱时才需要开启。开启会增加
MaterialPropertyBlock的开销,一般情况下保持关闭。 - Tint Black (!):只有Spine动画中使用了“黑色染色”(Tint Black)功能时才需要开启。开启后顶点数据量会增加(需要UV2和UV3),如果没用到这个功能,关闭它以节省带宽。
- Add Normals / Solve Tangents:只有你的着色器需要法线或切线信息(比如使用标准光照、法线贴图)时才开启。对于普通的2D Sprite着色器,关闭它们。
5.2 常见问题速查与解决方案
下表列出了使用SkeletonAnimation时最常遇到的“坑”及其解决方法:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 动画播放卡在第一帧 | 在Update中每帧调用SetAnimation。 | 改为状态驱动,只在状态变化时调用。使用PlayAnimation方法中的防重复逻辑。 |
| 皮肤切换后,旧皮肤附件仍可见 | 切换皮肤后没有调用SetSlotsToSetupPose。 | 切换皮肤后务必调用_skeleton.SetSlotsToSetupPose();和_animationState.Apply(_skeleton);。 |
| 代码修改骨骼位置无效 | 修改时机不对,被动画覆盖。 | 将脚本执行顺序设为比SkeletonAnimation早,或订阅UpdateLocal/UpdateComplete回调在合适时机修改。 |
| 动画事件没有触发 | 1. 事件名称拼写错误。 2. 监听器没有被正确添加或已移除。 3. 动画被中断(Interrupt), Complete事件不会触发。 | 1. 使用[SpineEvent]属性特性避免拼写错误。2. 在 Awake或Start中确保添加了监听。3. 同时监听 End和Complete事件,或使用WaitForSpineAnimationEnd。 |
| 角色显示为紫色(Missing Material) | 1.SkeletonDataAsset引用丢失。2. Atlas贴图导入设置错误(非PMA格式错误)。 3. 着色器不匹配(如UI用了非UI Shader)。 | 1. 检查Inspector中的赋值。 2. 检查Atlas贴图导入设置,确保 Texture Type为Sprite (2D and UI),并根据Spine导出设置勾选/不勾选Alpha Is Transparency。3. 对于 SkeletonGraphic,必须使用Spine/SkeletonGraphic开头的着色器。 |
| 叠加动画(如受伤抖动)效果奇怪 | 轨道混合模式(MixBlend)设置错误。 | 对于叠加动画(如受击、表情),通常应设置entry.MixBlend = MixBlend.Additive;。基础动画用MixBlend.First或MixBlend.Replace。 |
| 在UI Canvas中使用SkeletonGraphic,动画不更新 | Canvas组件的Additional Shader Channels未启用所需通道。 | 选中父级Canvas,在Inspector中确保TexCoord1和TexCoord2(如果用了Tint Black)是启用的。 |
| 动画过渡(Mix)生硬,没有平滑 | 1. Spine编辑器中未设置动画间的Mix Duration。2. 代码中 SetAnimation时,新旧动画相同,但Mix被跳过。3. 使用 SkeletonMecanim时,目标动画首帧不是关键帧。 | 1. 在Spine中设置合理的混合时间。 2. 参考上文脚本,避免重复设置相同动画。 3. 对于 SkeletonMecanim,确保过渡到的动画其首帧所有受影响的骨骼都有关键帧。 |
5.3 内存与实例化优化
- 对象池:对于频繁创建销毁的角色(如子弹、特效、敌人),不要直接
Instantiate和Destroy带有SkeletonAnimation的Prefab。应该使用对象池,并在复用对象时,调用SkeletonAnimation的Initialize(true)进行完全重置,或者手动调用skeleton.SetToSetupPose()和animationState.ClearTracks()来清理状态。 - 共享SkeletonDataAsset:同一个角色Prefab的多个实例,会共享引用的
SkeletonDataAsset。这是Unity的资源管理机制,是高效的。但要避免在运行时动态加载并创建多个相同的SkeletonDataAsset实例。 - Texture Atlas合并:尽可能让一个角色所有皮肤、附件的图片合并到一张大图集(Atlas)中。多张图集会增加Draw Call。在Spine中导出时注意打包设置。
6. 实战:一个可复用的增强型Spine动画控制器
结合以上所有要点,我提供一个在生产环境中使用过的、功能更全面的控制器脚本。它封装了状态管理、动画队列、事件转发等功能,并处理了许多边界情况。
using System; using System.Collections.Generic; using Spine; using Spine.Unity; using UnityEngine; using UnityEngine.Events; [RequireComponent(typeof(SkeletonAnimation))] public class RobustSpineController : MonoBehaviour { public SkeletonAnimation SkeletonAnimation { get; private set; } public Skeleton Skeleton { get; private set; } public AnimationState AnimationState { get; private set; } [Header("Animation References")] [SpineAnimation] public string defaultIdleAnimation = "idle"; [Header("Settings")] public float globalTimeScale = 1f; public bool pauseWithGame = true; // 事件系统,方便在Inspector中配置 [System.Serializable] public class SpineEventUnityEvent : UnityEvent<string, float> { } public SpineEventUnityEvent OnCustomEventReceived; private Dictionary<string, TrackEntry> _activeTrackEntries = new Dictionary<string, TrackEntry>(); private string _currentMainAnimation; void Awake() { InitializeReferences(); RegisterLifecycleCallbacks(); } void InitializeReferences() { SkeletonAnimation = GetComponent<SkeletonAnimation>(); SkeletonAnimation.Initialize(false); Skeleton = SkeletonAnimation.Skeleton; AnimationState = SkeletonAnimation.AnimationState; SkeletonAnimation.timeScale = globalTimeScale; } void RegisterLifecycleCallbacks() { // 监听所有自定义事件,并转发到UnityEvent AnimationState.Event += (entry, e) => { OnCustomEventReceived?.Invoke(e.Data.Name, e.Float); }; // 可选:监听全局动画开始/结束,用于日志或全局状态机 AnimationState.Start += (entry) => { /* Debug.Log($"动画开始: {entry.Animation.Name}"); */ }; AnimationState.Complete += (entry) => { /* 处理非循环动画结束 */ }; AnimationState.End += (entry) => { /* 动画被中断或自然结束 */ }; } void Update() { if (pauseWithGame && Mathf.Approximately(Time.timeScale, 0)) { SkeletonAnimation.timeScale = 0; } else { SkeletonAnimation.timeScale = globalTimeScale; } } /// <summary> /// 安全地播放主轨道动画,带有防重复和混合控制。 /// </summary> public TrackEntry PlayMainAnimation(string animName, bool loop = true, float mixDuration = -1f) { if (string.IsNullOrEmpty(animName) || _currentMainAnimation == animName) { return AnimationState.GetCurrent(0); } TrackEntry entry; if (mixDuration >= 0) { // 使用自定义混合时间 float oldMix = AnimationState.Data.DefaultMix; AnimationState.Data.DefaultMix = mixDuration; entry = AnimationState.SetAnimation(0, animName, loop); AnimationState.Data.DefaultMix = oldMix; } else { entry = AnimationState.SetAnimation(0, animName, loop); } _currentMainAnimation = animName; _activeTrackEntries["main"] = entry; return entry; } /// <summary> /// 在指定轨道播放叠加动画。 /// </summary> public TrackEntry PlayAdditiveAnimation(int track, string animName, bool loop, float mixDuration = 0.1f, MixBlend blendMode = MixBlend.Additive) { var entry = AnimationState.SetAnimation(track, animName, loop); entry.MixBlend = blendMode; entry.MixDuration = mixDuration; _activeTrackEntries[$"track{track}"] = entry; // 设置一个完成监听,自动清理轨道(对于非循环动画) if (!loop) { entry.Complete += (trackEntry) => { if (_activeTrackEntries.TryGetValue($"track{track}", out var oldEntry) && oldEntry == trackEntry) { AnimationState.ClearTrack(track); _activeTrackEntries.Remove($"track{track}"); } }; } return entry; } /// <summary> /// 平滑停止所有动画(使用空动画混合)。 /// </summary> public void StopAllAnimationsSmoothly(float mixDuration = 0.3f) { for (int i = 0; i < AnimationState.Tracks.Count; i++) { if (AnimationState.GetCurrent(i) != null) { AnimationState.SetEmptyAnimation(i, mixDuration); } } _activeTrackEntries.Clear(); _currentMainAnimation = null; } /// <summary> /// 切换皮肤,并自动处理SetupPose。 /// </summary> public bool SetCharacterSkin(string skinName) { bool success = Skeleton.SetSkin(skinName); if (success) { Skeleton.SetSlotsToSetupPose(); AnimationState.Apply(Skeleton); } return success; } /// <summary> /// 获取骨骼的世界空间位置,用于特效附着等。 /// </summary> public Vector3 GetBoneWorldPosition(string boneName) { var bone = Skeleton.FindBone(boneName); if (bone == null) { Debug.LogError($"未找到骨骼: {boneName}"); return transform.position; } return bone.GetWorldPosition(SkeletonAnimation.transform); } void OnDisable() { // 当对象被禁用时(如放入对象池),清理状态是个好习惯 // AnimationState.ClearTracks(); // Skeleton.SetToSetupPose(); } void OnDestroy() { // 清理委托引用,防止内存泄漏 if (AnimationState != null) { // 注意:这里移除了我们通过lambda添加的监听器。 // 对于使用具名方法添加的监听器,需要在这里显式移除。 // 本例中使用的lambda表达式会随着对象销毁而自动释放,但显式清理是好习惯。 } } }这个脚本提供了更健壮的控制,包括时间缩放、暂停支持、事件转发、安全的动画播放和叠加,以及一些工具方法。你可以直接将它挂载到你的Spine角色上,并通过其他系统(如状态机、输入控制器)来调用它的公共方法。
最后,关于SkeletonMecanim和SkeletonGraphic,它们本质是SkeletonAnimation在不同场景下的变体。SkeletonMecanim让你能用Unity的Animator Controller来控制Spine动画,适合需要与Unity动画系统深度集成(如复杂的状态机、动画层)的项目,但会损失一些Spine原生的混合控制精度。SkeletonGraphic则是为了在UI系统中使用,它继承自MaskableGraphic,可以和UI遮罩、Canvas Group一起工作,但要注意其材质和合批限制。
选择哪个组件,取决于你的具体需求。对于大多数游戏内的2D角色动画,SkeletonAnimation因其功能最全、控制最直接,仍然是首选。理解它的原理,避开本文提到的这些“坑”,你就能让Spine动画在Unity中流畅、稳定地运行起来。