1. 为什么要在 TimeLine 里写自定义脚本
Unity 的 TimeLine 刚出来那会儿,很多人把它当成一个“高级动画播放器”——拖几个 Animation Clip 进去,连一连,点播放,完事。但真正在项目里用过一段时间就会发现,纯靠内置的 Animation Track、Audio Track、Activation Track 根本不够用。比如你想在某个时间点让角色播放一个特效、想根据剧情分支动态改变后续的对话内容、想在镜头切过去的同时让某个物体做一段程序化运动,这些需求内置轨道要么做不了,要么做起来极其别扭。
自定义脚本轨道就是来解决这类问题的。它的本质是让你在 TimeLine 的时间轴上挂载自己的逻辑代码,在指定的时间点或时间段内执行任意操作。你可以把它理解成“带时间参数的 MonoBehaviour”——平时写Update()是每帧执行,而 TimeLine 的自定义轨道让你可以精确控制“第几秒到第几秒执行什么”。
这篇文章适合两类人:一类是已经用过 TimeLine 基础功能、想进一步做剧情演出或过场动画的开发者;另一类是被策划需求逼得没办法、需要让 TimeLine 和游戏逻辑深度联动的程序员。不管你是哪种,下面这些内容都是我实际项目里踩过坑之后总结出来的,不是照搬文档。
2. 自定义轨道的三种类型与选型逻辑
2.1 先搞清楚 Playable 体系的基本结构
在动手写代码之前,有必要把 TimeLine 底层的 Playable 体系理一遍。不然后面看到PlayableBehaviour、PlayableAsset、TrackAsset这三个类会一头雾水。
Unity 的 TimeLine 底层是基于 Playable Graph 构建的。你可以把 Playable Graph 想象成一棵“执行树”:根节点是 TimeLine 本身,下面挂着若干轨道,每条轨道下面又挂着若干 Clip。数据在这棵树里从上往下流动,每个节点都可以对数据进行处理。
在这个体系里,三个核心类各司其职:
- TrackAsset:定义轨道的类型。它决定了这条轨道能放什么类型的 Clip、在 Inspector 里长什么样、绑定什么类型的对象。你可以把它理解为“轨道的模板”。
- PlayableAsset:定义 Clip 的数据。每个 Clip 在时间轴上是一个数据块,PlayableAsset 就是用来描述这个数据块里存了什么。比如一个特效 Clip,里面存的是特效预制体的引用和缩放参数。
- PlayableBehaviour:定义 Clip 的执行逻辑。它负责在 TimeLine 播放到该 Clip 时执行具体操作,比如实例化特效、修改属性、发送事件等。
这三个类的关系是:TrackAsset 创建 Clip,Clip 持有 PlayableAsset 的数据,PlayableAsset 在运行时创建对应的 PlayableBehaviour 来执行逻辑。理解了这个链条,后面写代码就是填空。
2.2 三种轨道类型分别适合什么场景
根据实际项目经验,自定义轨道大致可以分为三类,选型的时候先想清楚你的需求属于哪一类:
第一类:事件型轨道。特点是“到了某个时间点触发一次,触发完就结束”。比如播放音效、发送消息、激活某个 GameObject。这类轨道的 Clip 通常很短,甚至只有一个时间点。实现上重点是PlayableBehaviour的OnBehaviourPlay回调,在里面执行一次性操作就行。
第二类:持续型轨道。特点是“在一段时间内持续影响某个对象”。比如让一个物体的透明度从 0 渐变到 1、让相机在 5 秒内从 A 点移动到 B 点。这类轨道需要在PrepareFrame里根据当前时间计算插值,重点是处理好起始和结束的边界。
第三类:混合型轨道。既有持续影响,又需要在开始和结束时做额外操作。比如一段“角色被击飞”的演出:开始时播放受击动画和音效,持续期间控制位移曲线,结束时恢复状态并发送事件。这类轨道最复杂,但也最常用。
选型的时候有个简单判断标准:如果你的需求用OnBehaviourPlay一行代码就能搞定,选第一类;如果需要在PrepareFrame里持续计算,选第二类;如果两者都要,选第三类。
2.3 什么时候不该用自定义轨道
不是所有需求都适合往 TimeLine 里塞。我见过有同事把整个战斗逻辑都写成 TimeLine 轨道,结果调试的时候痛苦不堪。以下几种情况建议慎重:
- 逻辑需要频繁与外部交互的:比如需要实时读取玩家输入来决定后续行为的,TimeLine 的时间驱动模型不适合。
- 需要复杂状态机的:TimeLine 本质是线性的,虽然可以通过信号和分支做一定程度的非线性,但复杂状态流转还是交给专门的状态机更合适。
- 性能敏感的每帧操作:TimeLine 的
PrepareFrame每帧都会调用,如果在里面做大量计算,性能开销不容忽视。
提示:自定义轨道最适合的场景是“演出控制”——在正确的时间点做正确的事,而不是承载核心游戏逻辑。
3. 从零搭建一条自定义轨道的完整流程
3.1 创建 TrackAsset 子类并配置轨道属性
第一步是定义轨道类型。新建一个 C# 脚本,继承TrackAsset,然后加上[TrackClipType]和[TrackBindingType]两个特性。
using UnityEngine; using UnityEngine.Timeline; [TrackClipType(typeof(MyCustomClip))] [TrackBindingType(typeof(Transform))] public class MyCustomTrack : TrackAsset { // 这里可以定义轨道级别的参数 public string trackLabel = "默认标签"; }TrackClipType告诉 Unity 这条轨道只能放MyCustomClip类型的 Clip。TrackBindingType指定这条轨道绑定什么类型的对象——上面写的是Transform,意味着在 TimeLine 面板上这条轨道会有一个绑定槽,你可以把场景里的某个 Transform 拖进去。
这里有个容易踩的坑:TrackBindingType如果写GameObject,绑定槽接受的是整个物体;如果写具体组件类型,绑定槽只接受该组件。选哪个取决于你的逻辑需要操作什么。如果只是控制位移和旋转,绑Transform就够了;如果需要调用组件上的方法,就绑具体组件类型。
轨道类里还可以重写CreateTrackMixer方法来自定义混合逻辑,不过大多数情况下用默认的就行,后面讲到混合的时候再展开。
3.2 定义 Clip 的数据载体 PlayableAsset
接下来定义 Clip 的数据结构。新建一个脚本继承PlayableAsset,在里面声明你需要的公开字段,这些字段会显示在 Clip 的 Inspector 面板上。
using UnityEngine; using UnityEngine.Playables; using UnityEngine.Timeline; public class MyCustomClip : PlayableAsset, ITimelineClipAsset { [Header("位移设置")] public Vector3 startOffset = Vector3.zero; public Vector3 endOffset = Vector3.zero; [Header("曲线")] public AnimationCurve moveCurve = AnimationCurve.Linear(0, 0, 1, 1); public ClipCaps clipCaps { get { return ClipCaps.Blending | ClipCaps.Extrapolation; } } public override Playable CreatePlayable(PlayableGraph graph, GameObject owner) { var playable = ScriptPlayable<MyCustomBehaviour>.Create(graph); var behaviour = playable.GetBehaviour(); behaviour.startOffset = startOffset; behaviour.endOffset = endOffset; behaviour.moveCurve = moveCurve; return playable; } }ITimelineClipAsset接口的clipCaps属性决定了这个 Clip 支持哪些能力。Blending表示支持混合,Extrapolation表示支持外推(Clip 结束后继续影响)。如果你不需要这些能力,可以不实现这个接口,但建议至少实现一下,后面调整起来方便。
CreatePlayable方法是核心——它负责把 Clip 的数据传递给PlayableBehaviour。注意这里创建的是ScriptPlayable<T>,泛型参数就是你的 Behaviour 类型。
3.3 编写 PlayableBehaviour 的执行逻辑
Behaviour 是真正干活的地方。它有几个关键回调需要理解:
using UnityEngine; using UnityEngine.Playables; public class MyCustomBehaviour : PlayableBehaviour { public Vector3 startOffset; public Vector3 endOffset; public AnimationCurve moveCurve; private Transform targetTransform; private Vector3 originalPosition; private bool isFirstFrame = true; public override void OnGraphStart(Playable playable) { // 整个 Playable Graph 开始时调用一次 } public override void OnBehaviourPlay(Playable playable, FrameData info) { // Clip 开始播放时调用 isFirstFrame = true; } public override void PrepareFrame(Playable playable, FrameData info) { // 每帧调用,核心逻辑写在这里 if (targetTransform == null) return; if (isFirstFrame) { originalPosition = targetTransform.position; isFirstFrame = false; } double duration = playable.GetDuration(); double time = playable.GetTime(); float normalizedTime = duration > 0 ? (float)(time / duration) : 0f; float curveValue = moveCurve.Evaluate(normalizedTime); Vector3 offset = Vector3.Lerp(startOffset, endOffset, curveValue); targetTransform.position = originalPosition + offset; } public override void OnBehaviourPause(Playable playable, FrameData info) { // Clip 暂停或结束时调用 } public override void OnGraphStop(Playable playable) { // 整个 Graph 停止时调用 } }几个关键点需要展开说:
PrepareFrame是每帧调用的,但它的调用时机在正常的Update之前。这意味着如果你在PrepareFrame里修改了 Transform,同一帧的LateUpdate里读到的就是修改后的值。
playable.GetTime()返回的是当前 Clip 已经播放的时间,playable.GetDuration()返回 Clip 的总时长。用这两个值可以算出归一化时间,再配合AnimationCurve就能做出各种缓动效果。
OnBehaviourPlay和OnBehaviourPause是一对,分别在 Clip 进入和离开播放状态时触发。注意OnBehaviourPause不仅在 Clip 正常结束时触发,在 TimeLine 被暂停或跳转时也会触发,所以里面的逻辑要能处理这些情况。
3.4 把轨道绑定到场景对象上
代码写完之后,回到 Unity 编辑器。在 TimeLine 面板上右键,选择你创建的轨道类型,然后把场景里的对象拖到轨道的绑定槽上。接着在轨道上右键创建 Clip,选中 Clip 就能在 Inspector 里看到你定义的那些字段了。
这里有个细节:TrackBindingType指定的类型必须和拖进去的对象匹配,否则绑定槽会显示为红色。如果绑的是Transform,拖一个只有RectTransform的 UI 对象进去是不行的——虽然RectTransform继承自Transform,但 TimeLine 的类型检查比较严格。
注意:如果绑定槽一直显示“None”,检查一下轨道类的
TrackBindingType是否写对了,以及场景对象上是否有对应类型的组件。
4. 进阶技巧:混合、外推与运行时动态控制
4.1 让多个 Clip 平滑过渡的混合逻辑
当两个同类型的 Clip 在时间轴上重叠时,TimeLine 会自动进行混合。默认的混合方式是线性插值,但你可以通过重写TrackAsset的CreateTrackMixer来自定义混合逻辑。
public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount) { var mixer = ScriptPlayable<MyMixerBehaviour>.Create(graph, inputCount); return mixer; }MyMixerBehaviour继承自PlayableBehaviour,它的PrepareFrame会收到所有活跃 Clip 的输入。你可以通过playable.GetInputCount()和playable.GetInput(i)来遍历每个输入,根据权重进行混合。
混合权重的计算是 TimeLine 自动处理的——当两个 Clip 重叠时,前一个 Clip 的权重逐渐降低,后一个逐渐升高。你只需要在 Mixer 里读取每个输入的权重,然后按权重叠加效果就行。
实际项目中,混合最常用的场景是“动作过渡”。比如角色从走路切换到跑步,两个动画 Clip 重叠的部分通过 Mixer 做平滑过渡,避免动作突变。
4.2 外推模式让 Clip 结束后继续生效
默认情况下,Clip 结束后它对对象的影响就消失了。但有些场景需要“保持最后的状态”——比如一个让物体变透明的 Clip,播完之后物体应该保持透明,而不是突然恢复。
这时候就需要用到外推(Extrapolation)。在 Clip 的 Inspector 面板上,把Post-Extrapolate勾上,Clip 结束后会继续保持最后一帧的状态。对应的,Pre-Extrapolate让 Clip 在开始之前就生效。
外推的实现依赖于PrepareFrame里的时间判断。当playable.GetTime()超过GetDuration()时,说明 Clip 已经结束,但外推模式下PrepareFrame仍然会被调用。你需要在代码里处理这种情况:
double time = playable.GetTime(); double duration = playable.GetDuration(); float normalizedTime = duration > 0 ? Mathf.Clamp01((float)(time / duration)) : 0f;用Mathf.Clamp01把归一化时间限制在 0 到 1 之间,这样即使时间超出了,计算结果也会保持在边界值上。
4.3 运行时动态修改 Clip 数据的正确姿势
有时候需要在运行时根据游戏状态修改 Clip 的参数。比如根据玩家选择的难度调整演出时长,或者根据剧情分支改变位移目标。
直接修改PlayableAsset上的字段是不行的——因为PlayableAsset是资源文件,运行时修改不会生效,而且会影响所有使用该资源的实例。正确的做法是通过PlayableDirector获取运行时的PlayableGraph,然后直接操作PlayableBehaviour:
PlayableDirector director = GetComponent<PlayableDirector>(); PlayableGraph graph = director.playableGraph; if (graph.IsValid()) { int outputCount = graph.GetOutputCount(); for (int i = 0; i < outputCount; i++) { var output = graph.GetOutput(i); var sourcePlayable = output.GetSourcePlayable(); // 遍历 Playable 树,找到目标 Behaviour // 然后修改它的字段 } }这段代码看起来有点绕,实际项目中更推荐的做法是:在PlayableBehaviour里留一个“配置接口”,通过静态事件或单例来传递运行时参数。这样比直接遍历 Playable 树要清晰得多。
5. 实战中踩过的坑与排查清单
5.1 绑定对象为空导致的静默失败
这是最常见的问题。PrepareFrame里访问targetTransform时如果它是 null,代码不会报错,但什么效果都没有。更麻烦的是,如果绑定槽在编辑器里显示正常,但运行时绑定丢失了,排查起来很费时间。
解决方案是在OnBehaviourPlay里加一个空检查,并且打印明确的日志:
public override void OnBehaviourPlay(Playable playable, FrameData info) { if (targetTransform == null) { Debug.LogWarning($"[TimeLine] 轨道 {this.GetType().Name} 的绑定对象为空,请检查 TimeLine 面板上的绑定槽。"); return; } // ... }另外,PlayableBehaviour里没有直接获取绑定对象的 API,需要通过PlayableDirector.GetGenericBinding(trackAsset)来获取。如果你在 Behaviour 里需要访问绑定对象,可以在PlayableAsset.CreatePlayable的时候通过owner参数拿到PlayableDirector,然后查找到对应的绑定。
5.2 时间轴跳转导致的状态错乱
TimeLine 支持拖动进度条跳转,但跳转时OnBehaviourPlay和OnBehaviourPause的触发顺序可能和你预期的不一样。比如从 Clip A 中间跳到 Clip B 中间,Clip A 的OnBehaviourPause会触发,Clip B 的OnBehaviourPlay会触发,但 Clip A 的PrepareFrame可能不会再被调用了。
如果你的逻辑依赖于“每帧都在执行”,跳转后可能会出现状态不一致。解决办法是在OnBehaviourPlay里做完整的状态初始化,不要假设之前的帧已经执行过。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| Clip 播放但无效果 | 绑定对象为空 | 检查轨道绑定槽和TrackBindingType |
| 效果在编辑器预览正常,运行时无效 | PlayableAsset 数据未传递到 Behaviour | 检查CreatePlayable里的字段赋值 |
| 多个 Clip 重叠时效果异常 | 未实现 Mixer 或混合逻辑有误 | 重写CreateTrackMixer并检查权重计算 |
| Clip 结束后效果突然消失 | 未开启外推 | 在 Clip Inspector 里勾选 Post-Extrapolate |
| 跳转后状态错乱 | 依赖了帧间状态 | 在OnBehaviourPlay里做完整初始化 |
| 性能开销大 | PrepareFrame里做了重计算 | 缓存计算结果,避免每帧重复分配内存 |
5.4 几个容易被忽略的细节
Clip 的时长可以为零。如果你只需要一个时间点触发事件,可以把 Clip 拖到极短,然后在OnBehaviourPlay里执行逻辑。但注意时长为零的 Clip 在某些版本的 Unity 里可能不会触发PrepareFrame,所以逻辑要放在OnBehaviourPlay里。
Behaviour 的字段不要用引用类型做默认值。比如public List<GameObject> targets = new List<GameObject>();这种写法在 PlayableAsset 里没问题,但在 Behaviour 里可能会导致所有实例共享同一个列表。正确的做法是在OnGraphStart里初始化。
TimeLine 的预览模式和运行模式行为不同。在编辑器里拖动进度条预览时,PrepareFrame的调用频率和运行时不一样。不要依赖预览时的表现来判断逻辑是否正确,一定要实际运行测试。
信号(Signal)和自定义轨道可以配合使用。信号适合做“点事件”,自定义轨道适合做“持续影响”。两者结合可以覆盖绝大多数演出需求。比如用信号触发对话,用自定义轨道控制相机运动。
6. 一个完整的实战案例:剧情演出中的镜头控制
6.1 需求拆解与方案设计
假设有一个剧情演出场景:角色从画面左侧走到右侧,途中经过一个宝箱时停下,镜头拉近给宝箱一个特写,然后角色继续走到右侧出口。
这个需求可以拆成三条轨道:
- 角色移动轨道:控制角色从左侧走到宝箱位置,再从宝箱位置走到右侧出口。用自定义轨道实现,Clip 里定义起点和终点偏移。
- 镜头控制轨道:控制相机跟随角色,在宝箱处拉近。用自定义轨道实现,Clip 里定义目标位置和 FOV 变化。
- 事件轨道:在角色到达宝箱时触发“开箱”事件。用 Signal 实现。
6.2 镜头控制轨道的代码实现
镜头控制的核心是让相机在 TimeLine 播放期间平滑地移动到目标位置,同时调整 FOV。这里用Vector3.Lerp和Mathf.Lerp配合AnimationCurve来实现缓动。
public class CameraControlBehaviour : PlayableBehaviour { public Vector3 targetOffset; public float targetFOV = 60f; public AnimationCurve moveCurve = AnimationCurve.EaseInOut(0, 0, 1, 1); private Camera controlledCamera; private Vector3 startPosition; private float startFOV; private bool initialized; public override void OnBehaviourPlay(Playable playable, FrameData info) { initialized = false; } public override void PrepareFrame(Playable playable, FrameData info) { if (controlledCamera == null) return; if (!initialized) { startPosition = controlledCamera.transform.position; startFOV = controlledCamera.fieldOfView; initialized = true; } double duration = playable.GetDuration(); double time = playable.GetTime(); float t = duration > 0 ? Mathf.Clamp01((float)(time / duration)) : 0f; float curveT = moveCurve.Evaluate(t); controlledCamera.transform.position = Vector3.Lerp(startPosition, startPosition + targetOffset, curveT); controlledCamera.fieldOfView = Mathf.Lerp(startFOV, targetFOV, curveT); } }6.3 参数配置与效果调优
在 Clip 的 Inspector 里,targetOffset填相机相对于起始位置的偏移量。比如想让相机向前推 2 米,就填(0, 0, 2)。targetFOV填目标视野角度,从 60 拉到 40 就是推近效果。
moveCurve建议用EaseInOut曲线,这样相机启动和停止都有缓冲,看起来更自然。如果想让相机快速推近然后缓慢停下,可以用自定义曲线:前 30% 时间完成 70% 的位移,后 70% 时间完成剩余 30%。
实际调试的时候,我习惯先在 Scene 视图里把相机的起始和目标位置摆好,记录下偏移量,再填到 Clip 里。这样比盲填参数要快得多。
6.4 与信号系统配合处理事件触发
角色到达宝箱时触发开箱事件,用 Signal 来实现。在 TimeLine 上添加一个 Signal Track,在角色到达宝箱的时间点放置一个 Signal Emitter,然后在场景里创建一个 Signal Receiver 来处理事件。
Signal Receiver 上可以配置多个响应,每个响应绑定一个方法。比如“开箱”信号可以触发宝箱的动画播放、音效播放、粒子特效等。这样就把“时间点事件”和“持续控制”分开了,各司其职。
提示:Signal 和自定义轨道不要混用同一个逻辑。Signal 负责“点”,自定义轨道负责“线”,这样维护起来最清晰。
7. 性能优化与调试手段
7.1 减少 PrepareFrame 里的开销
PrepareFrame每帧都会调用,如果里面做了大量计算或者内存分配,性能影响会很明显。几个优化原则:
- 避免每帧 new 对象:比如不要在
PrepareFrame里创建新的Vector3数组或List。需要临时变量的话,在 Behaviour 里声明为字段,重复使用。 - 缓存计算结果:如果某些值在 Clip 播放期间不会变,在
OnBehaviourPlay里算一次就行,不要每帧重算。 - 减少 GetComponent 调用:在
OnBehaviourPlay里获取一次组件引用,缓存起来。
7.2 用 Profiler 定位 TimeLine 性能瓶颈
Unity Profiler 里可以看到 TimeLine 相关的 CPU 开销。重点关注PlayableDirector.Update和PlayableGraph.Evaluate这两项。如果这两项占用过高,说明 Playable Graph 的节点太多或者PrepareFrame里的逻辑太重。
另外,TimeLine 的轨道数量也会影响性能。每条轨道都会在 Graph 里创建一个节点,轨道越多,每帧遍历的开销越大。如果某些轨道在特定时间段内不需要,可以用 Activation Track 控制它们的启用状态。
7.3 编辑器下的调试技巧
在PrepareFrame里加Debug.Log是最直接的调试方式,但每帧都打印会刷屏。更好的做法是用Debug.DrawLine或Gizmos在 Scene 视图里可视化效果。
比如镜头控制轨道,可以在PrepareFrame里画一条从相机当前位置到目标位置的线:
Debug.DrawLine(controlledCamera.transform.position, startPosition + targetOffset, Color.green);这样在 Scene 视图里就能直观地看到相机的运动轨迹,比看日志快得多。
另外,TimeLine 面板支持在播放时实时修改 Clip 参数,修改会立即生效。利用这个特性可以在播放过程中微调参数,找到最合适的值之后再记录下来。
8. 一些个人经验与后续扩展方向
自定义轨道写多了之后,我发现最有价值的不是代码本身,而是“什么时候该用自定义轨道、什么时候不该用”的判断力。我见过太多项目把本该用代码直接解决的问题硬塞进 TimeLine,结果调试成本翻了好几倍。TimeLine 的强项是“时间轴上的可视化编排”,如果你的需求不需要在时间轴上直观地看到和调整,那大概率不该用 TimeLine。
另一个体会是:自定义轨道的代码要尽量“薄”。Behaviour 里只做和当前 Clip 直接相关的事,复杂的业务逻辑通过事件或接口抛出去,让专门的系统去处理。这样轨道代码容易复用,也容易测试。
后续如果要做更复杂的演出系统,可以考虑把自定义轨道和 ScriptableObject 结合——把演出配置做成数据资产,运行时动态生成 TimeLine 内容。这样策划可以在不碰代码的情况下配置新的演出,程序只需要维护好轨道的基础能力就行。这个方向我在一个小型项目里试过,效果不错,但前期的基础设施投入比较大,适合演出需求频繁且规律性强的项目。