Unity TimeLine自定义脚本:PlayableAsset与PlayableBehaviour实战指南
1. 为什么要在 TimeLine 里写自定义脚本Unity 的 TimeLine 刚出来那会儿很多人把它当成一个“高级动画播放器”——拖几个 Animation Clip 进去连一连点播放完事。但真正在项目里用过一段时间就会发现纯靠内置的 Playable 轨道根本不够用。比如你想在某个时间点让角色播放一个特效、切换一次相机、触发一段对话、修改一个材质参数甚至动态改变后续轨道的权重这些都不是拖拖拽拽能搞定的。这时候就得靠自定义脚本把 TimeLine 的能力撑开。说得直白一点TimeLine 本质上是一套基于 Playable 图的调度系统内置的那些轨道只是官方给你准备好的“积木”而自定义脚本就是让你自己造积木的工具。你可以写一个继承自PlayableAsset的资产类再配一个继承自PlayableBehaviour的行为类就能在 TimeLine 的任意时间点插入自己的逻辑。我刚开始接触这块的时候踩过一个很典型的坑以为只要在PlayableBehaviour的OnBehaviourPlay里写逻辑就行了结果发现预览模式下根本不触发必须进入 Play 模式才生效。后来才搞明白TimeLine 的预览和运行是两套不同的驱动路径ProcessFrame才是每帧都会被调用的那个入口。这个认知转变基本上是从“会用”到“用明白”的分水岭。这篇文章适合谁看如果你已经用过 TimeLine 的基础功能想进一步做运行时动态控制、自定义轨道、事件触发或者与游戏逻辑深度耦合那接下来的内容应该能帮你省下不少查文档和试错的时间。我会从整体设计思路讲到具体代码实现再到实际项目里遇到的坑和排查方法尽量把每个“为什么这么做”都讲清楚。2. 自定义脚本的整体设计与核心思路拆解2.1 TimeLine 的底层结构Playable 图到底是怎么回事要写自定义脚本先得对 TimeLine 的底层有个基本认知。Unity 的 TimeLine 并不是一个独立的系统它构建在Playable API之上。你可以把 Playable 图想象成一棵倒过来的树根节点是一个PlayableGraph下面挂着各种Playable节点每个节点负责输出一种数据——动画、音频、脚本逻辑等等。TimeLine 本身就是一个特殊的Playable它内部再管理若干条轨道每条轨道又对应一个或多个Playable。当你把一个PlayableAsset拖到 TimeLine 上时Unity 会在构建图的时候调用它的CreatePlayable方法把你的PlayableBehaviour实例挂到图里。之后每帧图被评估Evaluate时你的PlayableBehaviour就会收到回调。理解这条链路非常关键因为它决定了你的代码什么时候被执行、执行多少次、以及能不能拿到正确的上下文。提示PlayableGraph的构建和销毁是由 TimeLine 的PlayableDirector统一管理的不要试图自己去手动创建或销毁图否则很容易出现资源泄漏或者空引用。2.2 为什么选择 PlayableAsset PlayableBehaviour 这套组合Unity 给自定义 TimeLine 脚本提供的标准入口就是这两个类。PlayableAsset负责“数据”也就是你在 Inspector 里配置的那些参数PlayableBehaviour负责“行为”也就是运行时真正执行的逻辑。这种数据与行为分离的设计好处在于同一个行为类可以被多个资产复用每个资产带着不同的参数互不干扰。举个例子假设你要做一个“屏幕震动”的轨道。你可以写一个ScreenShakeBehaviour里面定义震动强度、持续时间、频率这些字段。然后在ScreenShakeAsset里暴露这些字段给策划配置。同一个ScreenShakeBehaviour可以挂在十个不同的ScreenShakeAsset上每个资产配置不同的参数互不影响。如果不用这套分离机制你就得为每种参数组合写一个单独的类维护成本会爆炸。另一个关键点是序列化。PlayableAsset是ScriptableObject的子类天然支持 Unity 的序列化系统可以直接在 Inspector 里编辑也能被 Prefab 引用。而PlayableBehaviour是普通类不参与序列化只负责运行时逻辑。这个分工非常清晰用起来也顺手。2.3 自定义轨道的两种形态Clip 轨道与混合轨道在实际项目里自定义脚本通常以两种形式出现。第一种是Clip 轨道也就是你创建一个TrackAsset指定它绑定的PlayableAsset类型然后在 TimeLine 上可以拖出一个个 Clip每个 Clip 有自己的起止时间和参数。这种适合“在某个时间段内做某件事”的场景比如播放一段特效、控制一段相机运动。第二种是混合轨道Mixer它没有 Clip 的概念整条轨道从头到尾都在运行你可以通过权重来控制多个输入之间的混合。这种适合“持续影响某个属性”的场景比如根据 TimeLine 进度动态调整全局音量、或者让多个动画层按权重叠加。选择哪种形态取决于你的需求是“离散事件”还是“连续影响”。我个人的经验是大部分游戏逻辑用 Clip 轨道就够了只有涉及到需要平滑过渡或多源混合的场景才需要上 Mixer。不要为了炫技而用 Mixer它的调试复杂度比 Clip 轨道高不少。2.4 生命周期回调什么时候该用哪个方法PlayableBehaviour提供了一系列虚方法理解它们的调用时机是用好自定义脚本的前提。下面这张表是我自己整理的核心回调对照回调方法调用时机典型用途OnGraphStart图开始运行时调用一次初始化全局状态OnGraphStop图停止时调用一次清理资源OnBehaviourPlayClip 开始播放时调用触发一次性事件OnBehaviourPauseClip 暂停或结束时调用收尾逻辑PrepareFrame每帧评估前调用准备数据ProcessFrame每帧评估时调用核心逐帧逻辑最容易混淆的是OnBehaviourPlay和ProcessFrame。前者只在 Clip 进入播放状态的那一帧触发一次后者是 Clip 处于激活状态的每一帧都会触发。如果你要做的是“进入这个时间段时触发一次对话”用OnBehaviourPlay如果是“在这个时间段内持续修改某个值”用ProcessFrame。还有一个隐藏的坑OnBehaviourPlay在预览模式下不会触发但ProcessFrame会。所以如果你的逻辑需要同时支持编辑期预览和运行时核心逻辑最好放在ProcessFrame里用状态标记来区分“第一次进入”和“持续更新”。3. 核心细节解析与实操要点3.1 PlayableAsset 的 CreatePlayable 方法该怎么写CreatePlayable是PlayableAsset的抽象方法你必须在这里返回一个Playable实例。最常见的写法是创建一个ScriptPlayableT其中T是你的PlayableBehaviour类型。代码大概长这样public class MyCustomAsset : PlayableAsset { public float intensity 1.0f; public string eventName DefaultEvent; public override Playable CreatePlayable(PlayableGraph graph, GameObject owner) { var playable ScriptPlayableMyCustomBehaviour.Create(graph); var behaviour playable.GetBehaviour(); behaviour.intensity intensity; behaviour.eventName eventName; return playable; } }这里有几个细节值得注意。第一ScriptPlayableT.Create会创建一个新的 Behaviour 实例但这个实例是挂在图上的不要试图把它存到静态变量里长期持有图销毁后它就失效了。第二参数传递是通过直接赋值完成的这意味着如果你在运行时修改了 Asset 上的字段已经创建的 Behaviour 不会自动同步需要手动处理。第三owner参数是挂载 TimeLine 的那个 GameObject可以用来获取场景中的其他组件。注意不要在CreatePlayable里做耗时操作比如FindObjectOfType或者加载资源。这个方法在图的构建阶段被调用频繁调用会拖慢 TimeLine 的初始化速度。需要引用外部对象的话在OnGraphStart或PrepareFrame里做缓存。3.2 PlayableBehaviour 中的 ProcessFrame 参数详解ProcessFrame的签名是public override void ProcessFrame(Playable playable, FrameData info, object playerData)。这三个参数各有用途但很多人只用了第一个忽略了后两个的价值。playable是当前 Behaviour 对应的 Playable 句柄你可以通过它获取时间信息比如playable.GetTime()拿到当前 Clip 内的本地时间playable.GetDuration()拿到总时长。做进度条或者按时间曲线插值的时候这两个方法非常有用。info是FrameData结构体里面包含deltaTime、weight、evaluationType等信息。weight在混合轨道里特别关键它表示当前输入的权重你可以用它来做淡入淡出。evaluationType可以区分当前是预览评估还是运行时评估这个在调试时很有帮助。playerData是轨道绑定的对象。如果你在 TrackAsset 上用了[TrackBindingType(typeof(Transform))]这样的特性那么playerData就是你在 Inspector 里绑定的那个 Transform。这个机制让你可以把 TimeLine 和场景对象关联起来是实现“轨道控制特定对象”的核心。3.3 自定义 TrackAsset 的绑定类型与轨道颜色写自定义轨道的时候TrackAsset上的特性决定了它在 Inspector 里的表现。最常用的两个特性是TrackBindingType和TrackColor。前者指定这条轨道需要绑定什么类型的对象后者设置轨道的显示颜色方便在 TimeLine 编辑器里区分。[TrackBindingType(typeof(Transform))] [TrackColor(0.2f, 0.8f, 0.4f)] public class MyCustomTrack : TrackAsset { public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount) { return ScriptPlayableMyCustomMixerBehaviour.Create(graph, inputCount); } }CreateTrackMixer是 TrackAsset 的核心方法它决定了这条轨道上的多个 Clip 如何混合。如果你不需要混合直接返回base.CreateTrackMixer或者一个空的 Mixer 就行。如果需要混合就在 Mixer 的ProcessFrame里遍历所有输入按权重计算最终结果。轨道颜色这个看似不起眼的细节在大型项目里其实很重要。一个 TimeLine 上可能有十几条轨道如果颜色都一样找起来非常费劲。我一般会按功能分类配色动画类用蓝色系特效类用橙色系逻辑类用绿色系音频类用紫色系。这样一眼扫过去就能定位到目标轨道。3.4 参数暴露与 Inspector 定制PlayableAsset的字段默认会在 Inspector 里显示但如果你想做更精细的控制可以用[Tooltip]、[Range]、[Header]这些特性来美化。比如[Header(震动参数)] [Range(0f, 10f)] public float intensity 1.0f; [Tooltip(震动持续的时间单位秒)] public float duration 0.5f;如果内置的 Inspector 不够用还可以写自定义的Editor类来绘制更复杂的界面。不过我的建议是除非确实有必要否则不要轻易上自定义 Editor。维护成本高而且 Unity 版本升级时容易出兼容问题。大部分情况下合理使用内置特性就能满足需求。还有一个实用技巧对于需要在运行时动态修改的参数可以把它标记为[NonSerialized]然后在 Behaviour 里通过其他方式获取。这样可以避免 Inspector 里出现一堆不该手动改的字段减少策划误操作的概率。4. 完整实操流程与核心环节实现4.1 从零搭建一个自定义事件轨道假设我们要做一个“时间点事件触发”的轨道功能是在 TimeLine 的特定时间点触发一个自定义事件比如播放音效、弹出 UI、或者通知游戏逻辑。这个需求在实际项目里非常常见用内置的 Signal 轨道也能做但自定义轨道更灵活可以携带更复杂的参数。第一步创建EventTrack[TrackBindingType(typeof(GameObject))] [TrackColor(0.9f, 0.6f, 0.2f)] public class EventTrack : TrackAsset { public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount) { return ScriptPlayableEventMixerBehaviour.Create(graph, inputCount); } }第二步创建EventAssetpublic class EventAsset : PlayableAsset { [Tooltip(事件标识符游戏逻辑根据这个字符串来分发)] public string eventKey; [Tooltip(事件携带的浮点参数)] public float floatParam; public override Playable CreatePlayable(PlayableGraph graph, GameObject owner) { var playable ScriptPlayableEventBehaviour.Create(graph); var behaviour playable.GetBehaviour(); behaviour.eventKey eventKey; behaviour.floatParam floatParam; return playable; } }第三步创建EventBehaviourpublic class EventBehaviour : PlayableBehaviour { public string eventKey; public float floatParam; private bool hasTriggered; public override void OnBehaviourPlay(Playable playable, FrameData info) { hasTriggered false; } public override void ProcessFrame(Playable playable, FrameData info, object playerData) { if (hasTriggered) return; if (info.evaluationType FrameData.EvaluationType.Preview) return; hasTriggered true; var target playerData as GameObject; if (target ! null) { EventDispatcher.Dispatch(eventKey, floatParam, target); } } public override void OnBehaviourPause(Playable playable, FrameData info) { hasTriggered false; } }第四步创建EventMixerBehaviourpublic class EventMixerBehaviour : PlayableBehaviour { public override void ProcessFrame(Playable playable, FrameData info, object playerData) { int inputCount playable.GetInputCount(); for (int i 0; i inputCount; i) { float weight playable.GetInputWeight(i); if (weight 0f) continue; var inputPlayable (ScriptPlayableEventBehaviour)playable.GetInput(i); var behaviour inputPlayable.GetBehaviour(); behaviour.ProcessFrame(inputPlayable, info, playerData); } } }这套代码的核心逻辑是Mixer 负责遍历所有激活的 Clip把ProcessFrame转发给每个 Behaviour。Behaviour 里用hasTriggered标记确保事件只触发一次。evaluationType的判断确保预览模式下不触发实际逻辑避免在编辑器里误操作。4.2 参数计算与时间曲线插值很多自定义轨道需要根据时间做插值比如让一个值从 A 平滑过渡到 B。这时候就需要用到playable.GetTime()和playable.GetDuration()。下面是一个典型的插值实现public override void ProcessFrame(Playable playable, FrameData info, object playerData) { double time playable.GetTime(); double duration playable.GetDuration(); float t duration 0 ? (float)(time / duration) : 0f; float currentValue Mathf.Lerp(startValue, endValue, t); ApplyValue(currentValue); }这里有个细节GetTime()返回的是 Clip 内的本地时间从 0 开始。如果你需要的是 TimeLine 的全局时间得通过PlayableDirector.time获取。另外t的计算要防止除零虽然正常情况下 duration 不会为 0但防御性编程总是好的。如果需要更复杂的曲线可以用AnimationCurve字段让策划在 Inspector 里画曲线public AnimationCurve valueCurve AnimationCurve.Linear(0, 0, 1, 1); // 在 ProcessFrame 里 float currentValue valueCurve.Evaluate(t);这种方式比线性插值灵活得多策划可以自己调整曲线的形状不需要改代码。我在项目里做相机抖动、UI 淡入淡出、材质参数过渡的时候基本都用AnimationCurve效果很好。4.3 运行时动态修改 TimeLine 内容有时候你需要在运行时动态添加或移除 Clip比如根据玩家选择播放不同的分支剧情。TimeLine 提供了PlayableDirector的 API 来支持这种操作但有几个坑需要注意。动态创建 Clip 的代码大概是这样var timelineAsset director.playableAsset as TimelineAsset; var track timelineAsset.GetOutputTrack(0) as EventTrack; var newClip track.CreateClipEventAsset(); newClip.start 5.0; newClip.duration 2.0; var asset newClip.asset as EventAsset; asset.eventKey DynamicEvent;关键点在于修改完 TimeLine 资产后如果PlayableDirector正在播放需要调用director.RebuildGraph()来重建图否则新加的 Clip 不会生效。但RebuildGraph会导致当前播放进度重置所以最好在暂停状态下操作或者记录时间后手动恢复。另一个坑是动态创建的 Clip 在 TimeLine 编辑器里不会自动显示需要调用EditorUtility.SetDirty并刷新窗口。不过这个只在编辑器下有意义运行时不需要关心。提示频繁调用RebuildGraph会有性能开销如果只是修改已有 Clip 的参数直接改 Asset 上的字段就行不需要重建图。只有增删 Clip 或改变轨道结构时才需要重建。4.4 与游戏逻辑的通信方式自定义 TimeLine 脚本和游戏逻辑之间的通信通常有三种方式。第一种是直接引用在 Behaviour 里通过playerData拿到绑定的对象直接调用它的方法。这种方式最简单但耦合度高适合小项目。第二种是事件系统就像前面例子里的EventDispatcher通过字符串或枚举来分发事件。这种方式解耦好但调试时不容易追踪事件的来源和去向。第三种是共享数据容器比如一个ScriptableObject或者单例TimeLine 脚本往里面写数据游戏逻辑从里面读。这种方式适合需要跨多个系统共享状态的场景。我个人的选择标准是如果 TimeLine 和逻辑是强相关的比如某个角色的专属动画事件用直接引用如果是通用的事件比如全局音效触发用事件系统如果是复杂的状态同步用共享数据容器。不要一刀切根据场景选最合适的。5. 常见问题与排查技巧实录5.1 预览模式下逻辑不执行怎么办这是最常见的问题没有之一。症状是在 TimeLine 编辑器里拖动播放头Scene 视图有变化但自定义逻辑没反应。原因通常是逻辑写在了OnBehaviourPlay里而这个回调在预览模式下不触发。解决方法有两个一是把核心逻辑移到ProcessFrame用状态标记控制执行次数二是在ProcessFrame里判断info.evaluationType如果是Preview就执行预览逻辑如果是Runtime就执行正式逻辑。我一般用第二种因为预览和运行时的行为可能确实需要不同处理。还有一种情况是PlayableDirector的playOnAwake没勾选或者Wrap Mode设置成了None导致图根本没启动。检查一下 Director 的 Inspector 设置确保Playable字段有值Update Method是Game Time或Unscaled Game Time。5.2 Clip 之间的过渡出现跳变当两个 Clip 相邻或者有重叠时如果参数没有正确处理会出现数值跳变。比如第一个 Clip 把透明度从 0 调到 1第二个 Clip 又从 0 开始中间就会闪一下。解决思路是让第二个 Clip 的起始值等于第一个 Clip 的结束值。可以在OnBehaviourPlay里读取当前的实际值作为起始值而不是用 Inspector 里配置的固定值。或者用 Mixer 的权重来做混合让两个 Clip 在重叠区域平滑过渡。如果用的是 Mixer检查ProcessFrame里是否正确处理了weight。权重为 0 的输入应该跳过权重在 0 到 1 之间的应该按比例混合。很多人忘了乘权重导致所有输入的效果直接叠加数值就爆了。5.3 运行时修改参数不生效有时候在代码里改了PlayableAsset的字段但运行时发现没变化。这是因为PlayableBehaviour的实例是在CreatePlayable时创建的参数是那一刻拷贝进去的。之后改 Asset 不会同步到已经创建的 Behaviour。解决方法是在ProcessFrame里直接从 Asset 读取或者提供一个刷新机制。不过更推荐的做法是需要动态修改的参数不要放在 Asset 上而是通过playerData或其他共享容器来传递。Asset 上的参数应该是“配置”不是“状态”。5.4 常见问题速查表问题现象可能原因排查方向预览无反应逻辑写在 OnBehaviourPlay移到 ProcessFrame运行时无反应Director 未启动或图未构建检查 playOnAwake 和 RebuildGraph数值跳变Clip 间参数不连续用 Mixer 权重混合或读取当前值参数不生效Asset 修改未同步到 Behaviour改为运行时读取或共享容器性能卡顿ProcessFrame 里做耗时操作缓存引用避免每帧查找事件重复触发缺少触发标记加 hasTriggered 状态位绑定对象为空TrackBindingType 不匹配检查 Inspector 绑定和类型声明5.5 几个我踩过的坑和独家技巧第一个坑是关于OnBehaviourPause的。这个回调不仅在 Clip 结束时触发在 TimeLine 暂停时也会触发。如果你在这里做资源释放暂停再播放就会出问题。正确的做法是用OnGraphStop做真正的清理OnBehaviourPause只做状态重置。第二个技巧是关于调试的。TimeLine 的逻辑出问题时光看代码很难定位。我一般会在ProcessFrame里加Debug.Log输出当前时间、权重、关键参数值。但要注意预览模式下Debug.Log会刷屏所以加个if (info.evaluationType FrameData.EvaluationType.Runtime)的条件判断。第三个技巧是关于性能的。ProcessFrame每帧都会调用如果里面有GetComponent、FindObjectOfType这类操作性能会很差。我的做法是在OnGraphStart里把所有需要的引用缓存到字段里ProcessFrame里只做纯计算。实测下来这个优化能让复杂 TimeLine 的帧率提升不少。第四个技巧是关于版本兼容的。不同 Unity 版本的 Playable API 有细微差异比如FrameData的字段名在某些版本里变过。如果你的项目需要跨版本建议把 TimeLine 相关的代码单独放在一个 asmdef 里方便做条件编译。另外升级 Unity 版本后一定要重新测试所有自定义轨道我遇到过升级后 Mixer 的权重计算行为变化的情况。最后一个心得不要试图用 TimeLine 做所有事情。它适合做时间轴驱动的、线性的、可预览的逻辑。如果是复杂的条件分支、状态机、或者需要频繁动态调整的逻辑用普通脚本或者专门的系统会更合适。TimeLine 和游戏逻辑的边界要划清楚否则后期维护会非常痛苦。