扩展Unity的Timeline系统
最近有些地方要用到 Timeline 这样的系统, 因为 Unity 自己提供了一套, 就直接拿来用了, 发现这套 Timeline 设计的比较复杂, 而且很多点都缺失, 甚至生命周期都不完善, 有点为了解耦而强行 MVC / MVVM 的设计思路, 扩展起来还是很麻烦的.
简单来说要做扩展只要生成两份代码就行了, 一个是继承 PlayableAsset, ITimelineClipAsset 的 Clip, 可以把它看成是创建 Timeline 行为的入口, 它既是入口, 又是编辑器工具下的可视化对象, 另一个是继承 PlayableBehaviour 的, 它就是实际的 Timeline 逻辑对象, 包含了一些运行生命周期.
首先问题就是生命周期的问题, 它是一个积分类型的系统, 并且缺失了 OnComplete 的结束回调.
看看 PlayableBehaviour 的主要生命周期重载:
public override void OnBehaviourPlay(Playable playable, FrameData info) { // 开始播放时触发 } public override void ProcessFrame(Playable playable, FrameData info, object playerData) { // 播放后每帧触发 }
如下图 OnBehaviourPlay 在系统运行到这个 Clip 的起点的时候, 会触发, 而如果系统运行到这个 Clip 还没结束的时候, 进行了暂停, 然后再次恢复播放的话, 它还是会触发 :
举例来说一个UI按钮的点击, 在进入状态时触发点击, 退出状态时再次点击, 那么在这里首先是无法实现, 然后是点击的次数无法控制, 因为每次暂停都可能造成错误触发点击.
那么就需要扩展一套生命周期的控制, 来完成补充生命周期以及触发控制逻辑了. 直接通过继承 PlayableBehaviour 来创建一个基类扩展, 其它只要继承就行了 :
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; namespace UnityTimelineExtention { [System.Serializable] public abstract class PlayableBaseBehaviour : PlayableBehaviour { // 全局控制 private static readonly Dictionary<PlayableDirector, HashSet<PlayableBaseBehaviour>> All = new Dictionary<PlayableDirector, HashSet<PlayableBaseBehaviour>>(); // 通用数据 [SerializeField] [Header("开始时间")] public double startTime = 0.0; [SerializeField] [Header("开始时间")] public double endTime = 0.0; // 缓存数据 protected PlayableDirector playableDirector { get; private set; } // 属性 public double duration { get { return endTime - startTime; } } protected bool Inited { get; set; } #region Main Funcs public override void OnBehaviourPlay(Playable playable, FrameData info) { base.OnBehaviourPlay(playable, info); if(false == Inited) { playableDirector = playable.GetGraph().GetResolver() as PlayableDirector; if(Application.isPlaying || CanPlayInEditorMode()) { All.GetValue(playableDirector).Add(this); // 结束回调, 写在功能前 UnityTimelineEventDispatcher.Instance.SetEndCall(playableDirector, this, (_tag) => { OnExit(); }); OnInit(playableDirector, info); } } Inited = true; } public override void ProcessFrame(Playable playable, FrameData info, object playerData) { base.ProcessFrame(playable, info, playerData); OnUpdate(playableDirector, info, playerData); } /// <summary> /// 只触发一次的接口 /// </summary> /// <param name="playable"></param> /// <param name="info"></param> public abstract void OnInit(PlayableDirector playableDirector, FrameData info); /// <summary> /// 每个动画帧触发 /// </summary> /// <param name="playableDirector"></param> /// <param name="info"></param> /// <param name="playerData"></param> public abstract void OnUpdate(PlayableDirector playableDirector, FrameData info, object playerData); /// <summary> /// 自动添加生命周期回调 /// </summary> public abstract void OnExit(); /// <summary> /// 标记, 是否能在编辑器下使用 /// </summary> /// <returns></returns> public virtual bool CanPlayInEditorMode() { return true; } #endregion #region Help Funcs public void ResetInitState() { Inited = false; } /// <summary> /// 重置Init状态 /// </summary> /// <param name="playableDirector"></param> public static void UnInit(PlayableDirector playableDirector) { var pool = All.TryGetNullableValue(playableDirector); if(pool != null) { foreach(var target in pool) { target.ResetInitState(); } } } /// <summary> /// 强制移除,重置Init状态 -- 小心使用 /// </summary> /// <param name="playableDirector"></param> /// <param name="target"></param> public static void RemoveFromPool(PlayableDirector playableDirector, PlayableBaseBehaviour target) { var pool = All.TryGetNullableValue(playableDirector); if(pool != null) { if(pool.Remove(target)) { target.ResetInitState(); } } } #endregion } }
这样就添加了生命周期, 重命名成了 OnInit, OnUpdate, OnExit 这样的三个, 只有 OnExit 是额外添加的, 需要通过另外的监视器 UnityTimelineEventDispatcher 来实现 :
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Playables; namespace UnityTimelineExtention { using Common; public class UnityTimelineEventDispatcher : Singleton<UnityTimelineEventDispatcher> { #region Defines public class PlayableEvent { public PlayableDirector playableDirector; public Common.ObsoluteTime timer; public double startTime; public double endTime; public double duration { get { return endTime - startTime; } } public System.Action endCall; public void Invoke() { if(endCall != null) { var temp = endCall; endCall = null; temp.Invoke(); } } } #endregion private bool _reigsted = false; private readonly Dictionary<PlayableDirector, MyDictionary<PlayableBaseBehaviour, PlayableEvent>> m_events = new Dictionary<PlayableDirector, MyDictionary<PlayableBaseBehaviour, PlayableEvent>>(); #region Overrides protected override void Initialize() { RegisterUpdate(); } protected override void UnInitialize() { if(Application.isPlaying) { Core.CoroutineRoot.instance.update -= Tick; } #if UNITY_EDITOR UnityEditor.EditorApplication.update -= Tick; #endif _reigsted = false; } #endregion #region Main Funcs public void SetEndCall<T>(PlayableDirector director, T target, System.Action<T> endCall) where T : PlayableBaseBehaviour { if(director) { if(target != null) { if(m_events.ContainsKey(director) == false) { director.stopped -= OnStop; director.stopped += OnStop; } PopEnd(director, director.time); var playableEvent = m_events.GetValue(director).GetValue(target); playableEvent.playableDirector = director; playableEvent.startTime = target.startTime; playableEvent.endTime = target.endTime; playableEvent.timer = new Common.ObsoluteTime(); playableEvent.endCall = () => { endCall.Invoke(target); }; } } } private void Tick() { foreach(var kv in m_events) { kv.Value.Remove((_tag, _playableEvent) => { if(_playableEvent.playableDirector) { var gap = _playableEvent.playableDirector.time - (_playableEvent.startTime + _playableEvent.duration - 0.001); if(gap >= 0.0) { _playableEvent.Invoke(); return true; } } else { if(_playableEvent.timer.ElapsedSeconds() >= _playableEvent.duration) { _playableEvent.Invoke(); return true; } } return false; }); } } #endregion #region Help Funcs private void PopEnd(PlayableDirector director, double time) { var events = m_events.TryGetNullableValue(director); if(events != null) { events.Remove((_tag, _playableEvent) => { if(time >= (_playableEvent.startTime + _playableEvent.duration)) { PlayableBaseBehaviour.RemoveFromPool(director, _tag); _playableEvent.Invoke(); return true; } return false; }); } } public void OnStop(PlayableDirector playableDirector) { if(playableDirector) { Debug.Log(playableDirector.gameObject.name + " Stopped"); var events = m_events.TryGetNullableValue(playableDirector); if(events != null) { PopEnd(playableDirector, playableDirector.duration + UnityTimelineTools.Epsilon); events.Clear(); } } } private void RegisterUpdate() { if(false == _reigsted) { _reigsted = true; if(Application.isPlaying) { Core.CoroutineRoot.instance.update -= Tick; Core.CoroutineRoot.instance.update += Tick; } else { #if UNITY_EDITOR UnityEditor.EditorApplication.update -= Tick; UnityEditor.EditorApplication.update += Tick; #endif } } } #endregion } }
这里主要就是通过监视 PlayableDirector 的时间来测试一个 PlayableBehaviour 是否已经到达结束, 触发它的 OnExit 方法.
这里需要一提的是在运行时的 PlayableBehaviour 并没有自己的开始结束时间信息, 需要从 TimelineClip 里面去获取, 而 TimelineClip 又是从 PlayableAsset 的 TrackAsset 中找, 需要绕一个大圈, 非常麻烦, 而我们在重载接口里得到的输入一般是 Playable 或者 PlayableGraph, 要获取 PlayableBehaviour 也是需要显式转换 :
playableDirector = playable.GetGraph().GetResolver() as PlayableDirector;
找资料都要找半天...
通过这样的方式来找对应 TimelineClip :
// find clip public static TimelineClip FindClip(PlayableDirector director, PlayableAsset asset) { if(director) { var trackAssets = ((TimelineAsset)director.playableAsset).GetOutputTracks(); foreach(var trackers in trackAssets) { foreach(var clip in trackers.GetClips()) { if(clip.asset == asset) { return clip; } } } } return null; } // find clip public static TimelineClip FindClip(PlayableGraph graphy, PlayableAsset asset) { var director = graphy.GetResolver() as PlayableDirector; return FindClip(director, asset); } // find clip public static TimelineClip FindClip(Playable playable, PlayableAsset asset) { return FindClip(playable.GetGraph(), asset); }
有了这些, 我们现在需要的就是创建代码了, 做个工具生成代码就行了:
两个代码 template :
1. Clip
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; namespace UnityTimelineExtention { public class #CLASSNAME#Clip : PlayableAsset, ITimelineClipAsset { // 用来同步数据的 public #CLASSNAME#Behaviour template; public ClipCaps clipCaps { get { return ClipCaps.None; } } // 重写初始化长度 //public override double duration //{ // get { return 0.5f; } //} public override Playable CreatePlayable(PlayableGraph graph, GameObject owner) { var playable = ScriptPlayable<#CLASSNAME#Behaviour>.Create(graph, template); var playable#CLASSNAME#Behaviour = playable.GetBehaviour(); var clip = UnityTimelineTools.FindClip(graph, this); if(clip != null) { playable#CLASSNAME#Behaviour.startTime = clip.start; playable#CLASSNAME#Behaviour.endTime = clip.end; } return playable; } } }
2. Behaviour
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; namespace UnityTimelineExtention { [System.Serializable] public class #CLASSNAME#Behaviour : PlayableBaseBehaviour { // 基类数据 /* playableDirector */ // 当前动作开始回调 public override void OnInit(PlayableDirector playableDirector, FrameData info) { // 功能 Debug.Log("#CLASSNAME#Behaviour Play"); } public override void OnUpdate(PlayableDirector playableDirector, FrameData info, object playerData) { // update } public override void OnExit() { // end Debug.Log("#CLASSNAME#Behaviour End"); } } }
这样生成的代码里面, XXXBehaviour 就有了生命周期了, 并且有了开始结束时间, 并且能限制 OnInit 在时间范围内只执行一次.
这里有个特殊的对象, TimelineAsset 也就是在一个 TimelineAsset 里面嵌套另外一个, 编辑器本身没有支持, 所以它并不是一个完美嵌套的设计, 我这里就直接按照普通对象来生成代码然后添加强行播放逻辑:
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; namespace UnityTimelineExtention { [System.Serializable] public class TimelineAssetPlayerBehaviour : PlayableBaseBehaviour { // 面板序列化数据 [SerializeField] public TimelineAsset timelineAsset; // 缓存数据 private PlayableDirector assetDirector; private bool isPlaying = false; public override void OnInit(PlayableDirector playableDirector, FrameData info) { // 功能 Debug.Log("TimelineAssetPlayerBehaviour Play"); if(timelineAsset) { if(assetDirector == false) { assetDirector = UnityTimelineTools.PlayTimeline(timelineAsset); } assetDirector.Pause(); assetDirector.time = 0; assetDirector.Evaluate(); isPlaying = true; } } public override void OnUpdate(PlayableDirector playableDirector, FrameData info, object playerData) { Tick((float)(playableDirector.time - this.startTime)); } public override void OnExit() { isPlaying = false; if(assetDirector) { assetDirector.time = assetDirector.duration + UnityTimelineTools.Epsilon; assetDirector.Evaluate(); assetDirector.Stop(); } } private void Tick(float elapsedTime) { if(isPlaying && assetDirector) { assetDirector.time = elapsedTime; assetDirector.Evaluate(); } } } }
在播放过程中使用 Evaluate 的方式来进行, 保证逻辑一致, 并且在结束时调用 Stop 能正确触发结束回调.
所以可见, 一个 Behaviour 的开始结束时间信息非常重要.
------------------- 一些小技巧 -------------------
Unity系统的序列化支持泛型对象了, 比如下面的我需要拖入一个 Transform 来序列化这个 Transform 的绝对缩放可以这样写 :
[System.Serializable] public class SavableSelector<T, V> where T : Component where V : struct { [SerializeField] public V Value; private System.Func<T, V> _selector; public SavableSelector(System.Func<T, V> selector) { _selector = selector; } [Sirenix.OdinInspector.ShowInInspector] [Sirenix.OdinInspector.LabelText("拖入对象 --> ")] public T Target { get { return null; } set { if(value) { this.Value = _selector.Invoke(value); } } } }
我在 XXXBehaviour 中这样来序列化 :
[SerializeField] [Header("记录原始缩放")] public SerializableDatas.SavableSelector<Transform, Vector3> Scaler = new SerializableDatas.SavableSelector<Transform, Vector3>((_trans) => { return _trans.lossyScale; });
面板上可以得到这样的显示, 序列化数据就直接获取即可:
var rawScale = Scaler.Value;
依赖 OdinInspector 做一些编辑器的显示简直太方便了.