4.Timeline源码浅析
4.1 为什么要学习 Timeline 源码
学习动机
Timeline 做技能编辑和过场动画都很常用,但要做深度定制(写自定义轨道、运行时动态控制、性能调优),光会在窗口里拖 clip 不够,得看源码才知道里面怎么回事。
Timeline 底层是 Playable 系统,关于 Playable 本身可以看前面的《10.Playable源码浅析》。
分层与本质
Timeline 源码的重点就三个:建图、选片段、写权重。
代码分两层。C# 这边处理轨道、片段、绑定这些上层逻辑,真正建图连节点、每帧评估写权重,走的是 C++ 的 Playable 系统,C# 通过 PlayableAPI 调过去:

Timeline 侧核心类:PlayableAsset、PlayableBinding、TrackAsset、PlayableDirector,决定播什么、绑给谁。Playable 侧:Playable、PlayableOutput、PlayableGraph,负责图的构建和评估:

Timeline 底层干的事就三步:把片段按时间排好,编译成 PlayableGraph,然后每帧用 IntervalTree 查当前时间该激活哪些 clip、写权重。IntervalTree 本质就是把”每帧找活跃 clip”从遍历数组变成区间查询,clip 多了之后查询性能差距就出来了。TimelinePlayable.PrepareFrame() 每帧拿查询结果决定谁播、权重多少。技能编辑也好,过场演出也好,走的都是 资产 → 建图 → 每帧评估 这条路。
Timeline 术语
Timeline 相关的词比较多,而且同一个词在不同上下文意思不一样,先捋清楚。
Timeline 这个词本身就有三个意思:
- Timeline 窗口 — 编排轨道和片段的编辑器面板。
- Timeline 文件 — 保存出来的
.playable文件。 - TimelineAsset —
.playable文件对应的类,继承PlayableAsset,也实现了ITimelineClipAsset(所以一条 Timeline 可以嵌套进另一条当 clip 用)。它持有所有轨道,建图时创建运行时的根节点。
轨道和片段相关的几个类:
- PlayableAsset — 引擎层的基类,定义了
CreatePlayable()和outputs。TimelineAsset、TrackAsset、各种 clip 资产都继承它。 - ITimelineClipAsset — 接口,就一个
clipCaps属性,声明这个 clip 支不支持混合、循环之类的能力。 - TrackAsset — 轨道。继承
PlayableAsset,下面挂着一堆TimelineClip。运行时建 Mixer,把 clip 的 Playable 塞进区间树。 - TimelineClip — 窗口里那一截一截的长条。存的是 start、duration、混合曲线,以及对一个 PlayableAsset 的引用(
asset字段)。注意它本身不创建 Playable,只是数据描述。通过asset as ITimelineClipAsset拿clipCaps。 - PlayableClipAsset(通称)—
TimelineClip背后真正的资产,比如AnimationPlayableAsset、ControlPlayableAsset。建图时被调CreatePlayable()创建实际节点。
绑定和驱动:
- PlayableBinding —
PlayableAsset.outputs里的一条,描述这个输出槽需要绑什么类型。Director Inspector 上看到的那些绑定槽就是从这来的。 - PlayableDirector — 挂在 GameObject 上的组件,持有 Timeline 资产引用,负责建图和播控。
运行时:
- TimelinePlayable — 建图时生成的根节点,继承
PlayableBehaviour。内部维护 IntervalTree,PrepareFrame里按时间查活跃 clip、写权重。
简略类图
classDiagram
direction LR
%% 继承(资产与轨道)
ScriptableObject <|-- PlayableAsset : 继承
PlayableAsset <|-- TimelineAsset : 继承
PlayableAsset <|-- TrackAsset : 继承
PlayableAsset <|-- ControlPlayableAsset : 继承
PlayableAsset <|-- AnimationPlayableAsset : 继承
%% 继承(驱动与行为)
Behaviour <|-- PlayableDirector : 继承
PlayableBehaviour <|-- TimelinePlayable : 继承
%% 关系:Asset、轨道、Binding、Director
PlayableAsset "1" --> "*" PlayableBinding : outputs 返回
TrackAsset "1" --> "*" PlayableBinding : outputs 返回
TimelineAsset "1" o-- "*" TrackAsset : 持有轨道
PlayableDirector "1" o-- "0..1" PlayableAsset : 持有 playableAsset
PlayableDirector ..> PlayableBinding : 读outputs显示绑定槽
%% 类型与职责
class ScriptableObject {
基类 资产基类
}
class PlayableAsset {
抽象类 可播放资产
CreatePlayable 创建节点()
outputs 输出集合
duration 时长
}
class TimelineAsset {
类 时间轴根资产
持有轨道 GetRootTracks/GetOutputTracks
outputs duration CreatePlayable
}
class TrackAsset {
抽象类 轨道资产
CreateTrackMixer 创建混合器()
outputs 输出描述
CompileClips 编译片段入树(internal)
}
class ControlPlayableAsset {
类 控制轨Clip资产
CreatePlayable 创建节点()
}
class AnimationPlayableAsset {
类 动画轨Clip资产
CreatePlayable 创建节点()
}
class PlayableBinding {
类 输出槽描述
sourceObject 源(轨道等)
outputTargetType 需绑类型
Director 据此显示绑定槽
}
class Behaviour {
类 组件基类
}
class PlayableDirector {
类 驱动组件
持PlayableAsset 建图 播控
读asset.outputs 显示绑定
SetGenericBinding 绑定()
}
class PlayableBehaviour {
抽象类 行为回调基类
PrepareFrame 帧前准备()
ProcessFrame 帧处理()
}
class TimelinePlayable {
类 运行时根Behaviour
静态 Create 创建根Playable()
实例 Compile 编译轨道入图()
PrepareFrame 选片段写权重()
}
源码学习路线
本篇博客按下面的顺序来拆解,基本上是从”看得见的”到”跑起来的”:
- 静态结构:先搞清楚 .playable 文件里存了什么。
TimelineAsset→TrackAsset→TimelineClip→ clip 资产,这棵树是一切的起点。把静态资产的组织方式弄明白了,后面看建图代码才知道它在遍历什么。 - 运行时建图:
PlayableDirector.Play()之后发生了什么?从TimelineAsset.CreatePlayable()到TimelinePlayable.Compile(),再到每条轨道的CreatePlayableGraph()→CompileClips(),整条调用链怎么把静态资产编译成 PlayableGraph 里的节点树。 - 每帧评估:图建好之后每帧怎么跑。
TimelinePlayable.PrepareFrame()用 IntervalTree 按当前时间查出哪些 clip 处于激活区间,给它们写权重、Pause 不在区间内的 clip。RuntimeClip、IntervalTree 内部结构(Rebuild 怎么建树、查询怎么走)、动画权重后处理一并在这里展开。
4.2 Timeline 静态结构
运行时的 PlayableGraph 是树形的,建图时读的是 .playable 资产实例化后的 TimelineAsset。在场景里被 PlayableDirector 引用并加载后,就会得到一份 TimelineAsset 实例,Director 初始化时用这份实例去创建根节点、遍历轨道与片段、生成各 Playable。也就是说:磁盘上的 .playable 是静态序列化;运行时真正在“指导”建图的是已经实例化好的 TimelineAsset。
编辑到运行时的链路:Timeline 窗口编辑资产 → 保存为 .playable 文件 → Director 引用该资产并设好 binding → Play 时实例化 TimelineAsset、建图生成各 Playable。
graph TD
subgraph S1["timeline 窗口编辑资产"]
A[生成 Timeline.playable 资产]
end
A --> B[director 添加 timeline 资产、设置 binding]
subgraph S2["director 组件初始化"]
C[根据 timeline 资产树实例化为对应的 TimelineAsset]
end
B --> C
窗口里加几条 Track、拖几个 clip,保存就是一份 .playable 资产。窗口上看到的东西和代码里的类直接对应:整块窗口 = TimelineAsset,一行轨道 = TrackAsset,轨道上那一截一截的长条 = TimelineClip(存 start / duration / 混合参数),TimelineClip 通过 m_Asset 引用真正 CreatePlayable() 的 PlayableAsset(Clip Asset)(如 AnimationPlayableAsset、AudioPlayableAsset)。下图蓝框 = TimelineAsset,红框 = TrackAsset,黄框 = TimelineClip 引用的 PlayableAsset。

层级上看就是:TimelineAsset 为根,下挂多条 TrackAsset,每条 TrackAsset 里放若干个 TimelineClip,每个 TimelineClip 再引用一个 PlayableAsset(Clip Asset)。

.playable 本质是 YAML。用文本编辑器打开能直接看到结构:根是 TimelineAsset(m_Name、m_Tracks 等),m_Tracks 里按 fileID 引用各 root track;每个 Track 子资产有 m_Parent 指回 TimelineAsset、m_Clips 里存着 TimelineClip,clip 里的 m_Start、m_Duration、m_Asset(fileID 指向 PlayableAsset)都能看到。反序列化时按 parent/children 和 m_Asset 的引用关系把整棵资产树还原出来。虽然 Track 和 ClipAsset 在 C# 里都继承 ScriptableObject,但 Unity 序列化统一用 classID 114 的 MonoBehaviour: 标签——YAML 里看到 MonoBehaviour 别以为是场景组件,就是序列化格式的历史包袱。下图左边是 YAML 原文,右边是对应的 Timeline 窗口,箭头标了层级和 m_Asset 的引用关系:

YAML 里每个子资产的 m_Script 字段带着 guid,和 .cs.meta 里的 guid 对上就能定位到具体的 C# 脚本,反序列化时靠这个还原成 TimelineAsset、TrackAsset、ActivationPlayableAsset 等正确的类型:

TimelineAsset:根资产
classDiagram
direction LR
%% 继承与实现
PlayableAsset <|-- TimelineAsset : 继承
class PlayableAsset {
抽象类 可播放资产
CreatePlayable()
outputs
duration
}
class TimelineAsset {
类 时间轴根资产
m_Tracks 根层轨道列表
duration durationMode fixedDuration
GetRootTracks() rootTrackCount
GetOutputTracks() outputTrackCount
outputs 汇总各output轨的outputs
CreatePlayable() 调TimelinePlayable.Create()
markerTrack editorSettings
}
TimelineAsset "1" o-- "*" TrackAsset : GetRootTracks返回
TimelineAsset ..> PlayableBinding : outputs 返回
TimelineAsset 里的 m_Tracks(List<ScriptableObject>)只存最外面一层轨道,就是 Timeline 窗口里直接看到的那一排。子轨挂在父轨的 children 里,不在这个列表。还有一条隐藏的 Marker 轨(m_MarkerTrack 字段),放 Signal Emitter 之类的时间点标记,也不在 m_Tracks 里,单独序列化。编辑器里往时间轴上方加 Signal 操作的就是这条。
获取轨道接口
源码里有两组访问接口,用途不同:
graph LR
subgraph 序列化字段
A["m_Tracks"]
B["m_MarkerTrack"]
end
A --> C["GetRootTracks()"]
B --> C
C -->|"编辑器层级
包含 GroupTrack"| D["rootTrackCount"]
A --> E["GetOutputTracks()"]
B --> E
E -->|"运行时建图
排除 Group / Override"| F["outputTrackCount"]
- GetRootTracks() 返回根层轨道,markerTrack 在前,m_Tracks 中的 TrackAsset 在后。对应编辑器里看到的层级。
- GetOutputTracks() 只返回运行时要建 PlayableOutput 的轨道。Group 轨纯粹是分组容器,Override 子轨跟着父轨走,都不在里面。后续 Compile、建图、绑定走的都是这个。
看下 UpdateRootTrackCache() 的实现就清楚了:
// TimelineAsset.cs
/// <summary>
/// 时间轴资源类
/// 表示时间轴的 PlayableAsset
/// 用于创建和管理时间轴序列,包含轨道、片段和标记
/// </summary>
[ExcludeFromPreset]
[Serializable]
[TimelineHelpURL(typeof(TimelineAsset))]
public partial class TimelineAsset : PlayableAsset, ISerializationCallbackReceiver, ITimelineClipAsset, IPropertyPreview
{
/// <summary>
/// 轨道资源列表。
/// </summary>
[HideInInspector, SerializeField] List<ScriptableObject> m_Tracks;
/// <summary>
/// 输出轨道缓存。
/// </summary>
[HideInInspector, NonSerialized] TrackAsset[] m_CacheOutputTracks;
/// <summary>
/// 根轨道缓存。
/// </summary>
[HideInInspector, NonSerialized] List<TrackAsset> m_CacheRootTracks;
/// <summary>
/// 更新根轨道缓存
/// </summary>
void UpdateRootTrackCache()
{
if (m_CacheRootTracks == null)
{
if (m_Tracks == null)
m_CacheRootTracks = new List<TrackAsset>();
else
{
m_CacheRootTracks = new List<TrackAsset>(m_Tracks.Count);
if (markerTrack != null)
{
m_CacheRootTracks.Add(markerTrack);
}
foreach (var t in m_Tracks)
{
var trackAsset = t as TrackAsset;
if (trackAsset != null)
m_CacheRootTracks.Add(trackAsset);
}
}
}
}
/// <summary>
/// 获取所有根轨道的可枚举列表。
/// </summary>
/// <returns>所有根轨道的 IEnumerable。</returns>
/// <remarks>根轨道是指出现在时间轴根部的所有轨道。这些是最外层的 GroupTracks 和不属于任何组的输出轨道。</remarks>
public IEnumerable<TrackAsset> GetRootTracks()
{
UpdateRootTrackCache();
return m_CacheRootTracks;
}
/// <summary>
/// 获取时间轴中所有输出轨道的列表。
/// </summary>
/// <returns>所有输出轨道的 IEnumerable</returns>
/// <remarks>
/// 输出轨道是生成 PlayableOutput 的轨道。通常,输出轨道是任何不是 GroupTrack 或子轨道的轨道。
/// </remarks>
public IEnumerable<TrackAsset> GetOutputTracks()
{
UpdateOutputTrackCache();
return m_CacheOutputTracks;
}
rootTrackCount 就是这个缓存列表的 Count。没加 Marker 时和 m_Tracks.Count 一样,加了 Signal 之后会多 1,写工具遍历轨道时注意别漏算。
Director Inspector 上那排绑定槽(Animator、AudioSource 等)来自 timelineAsset.outputs,内部就是遍历 GetOutputTracks() 把每条 track 的 outputs 拼到一起。
建图入口:CreatePlayable
PlayableDirector.Play() 和 RebuildGraph() 是 extern 方法,C++ 层建完 Graph 后回调 playableAsset.CreatePlayable(graph, go)。asset 是 TimelineAsset 时走到下面这段——GetOutputTracks() 取出要播的轨道,传给 TimelinePlayable.Create() 建根节点,最后把 duration 赋上去。一个 Director 对应一张 Graph,所有 output track 编译进同一张图:
graph LR A["Director.Play()"] -->|"C++ 回调"| B["TimelineAsset
.CreatePlayable()"] B --> C["GetOutputTracks()"] C --> D["TimelinePlayable.Create()
建根节点"] D --> E["设置 duration"]
// TimelineAsset.cs
public override Playable CreatePlayable(PlayableGraph graph, GameObject go)
{
// 编辑器下允许 clip 时间变动后 IntervalTree 自动重平衡
bool autoRebalanceTree = false;
#if UNITY_EDITOR
autoRebalanceTree = true;
#endif
// graph 为空说明是根 Timeline,需要建 PlayableOutput;嵌套的子 Timeline 不建
bool createOutputs = graph.GetPlayableCount() == 0;
var timeline = TimelinePlayable.Create(graph, GetOutputTracks(), go, autoRebalanceTree, createOutputs);
timeline.SetDuration(this.duration);
timeline.SetPropagateSetTime(true); // 根节点 setTime 时向下 propagate
return timeline.IsValid() ? timeline : Playable.Null;
}
duration 分两种模式:BasedOnClips 遍历所有 clip 取最大结束时间(走 CalculateItemsDuration()),FixedLength 直接读 m_FixedDuration(编辑器里手动改的时长就存在这)。建图过程不依赖 duration,只在最后赋给根 Playable。
Root Track 与 Output Track
.playable 反序列化之后会还原出一棵 PlayableAsset 树。根节点是 TimelineAsset,下面直接挂着的是 Root Track(窗口里最外层那一排),再往下 Group、Animation 轨还能继续嵌套 SubTrack,每条 Track 下面则是一堆 clip(每个 clip 背后也是一个 PlayableAsset)。
简单理一下概念:
- Root PlayableAsset —— 就是 TimelineAsset 本身,整棵树的根。
- Root Track —— 直接挂在 TimelineAsset 下的轨道,
GetRootTracks()返回的就是这批。 - Output Track —— 运行时会建 PlayableOutput、需要绑定 target 的轨道。GroupTrack、子 Group、Override 轨不产生 Output,不在此列。
这两套遍历各管各的:GetRootTracks 对应的是编辑器里看到的层级结构,GetOutputTracks 对应的是运行时真正要出 Output 的那几条轨道。Compile 走的是后者,Group 轨只在结构上存在,不产生 Output。下图中红框标的是 root track,黄框标的是 output track;像顶层单条 Animation/Activation 轨,既是 root 也是 output。

TrackAsset:轨道
TrackAsset 也继承自 PlayableAsset,是 Timeline 窗口里每条轨道对应的资产类型。AnimationTrack、AudioTrack、ActivationTrack 这些内置轨道都是它的子类,自定义轨道也要继承它。
classDiagram
direction LR
PlayableAsset <|-- TrackAsset : 继承
TrackAsset <|-- AnimationTrack
TrackAsset <|-- AudioTrack
TrackAsset <|-- ActivationTrack
TrackAsset <|-- ControlTrack
TrackAsset <|-- SignalTrack
TrackAsset <|-- GroupTrack
TrackAsset <|-- PlayableTrack
class TrackAsset {
abstract 轨道基类
m_Clips 片段列表
m_Children 子轨列表
m_Parent 父资产
m_Locked / m_Muted
GetClips() GetChildTracks()
CreateTrackMixer() 建Mixer节点
CompileClips() 编译clip为RuntimeClip
outputs 返回PlayableBinding
}
序列化字段
TrackAsset 里比较关键的序列化字段:
// TrackAsset.cs
[SerializeField] protected internal List<TimelineClip> m_Clips; // 这条轨上所有片段
[SerializeField] List<ScriptableObject> m_Children; // 子轨列表
[SerializeField] PlayableAsset m_Parent; // 父资产引用
[SerializeField] bool m_Locked; // 编辑器锁定
[SerializeField] bool m_Muted; // 编辑器静音
[SerializeField] AnimationClip m_Curves; // 轨道级参数动画曲线
m_Clips 对应窗口里那些长条片段(TimelineClip),m_Children 存子轨(比如 AnimationTrack 的 Override 子轨),m_Parent 指回父级(TimelineAsset 或 GroupTrack)。m_Curves 别和 clip 上的曲线搞混,这个是轨道自身的参数动画曲线。
GetClips() 和 GetChildTracks() 内部走缓存,没什么额外逻辑:
// TrackAsset.cs
public IEnumerable<TimelineClip> GetClips()
{
return clips; // 内部属性,m_Clips 的 ToArray() 缓存
}
public IEnumerable<TrackAsset> GetChildTracks()
{
UpdateChildTrackCache(); // 遍历 m_Children,as TrackAsset 转换后缓存
return m_ChildTrackCache;
}
子轨与 Override 轨
后面建图会频繁提到”子轨”,先说清楚。
大部分轨道在窗口里就是独立一行。AnimationTrack 特殊——右键选 “Add Override Track” 可以叠子轨,窗口里缩进显示,资产层面挂在父轨 m_Children 里:

Override 轨干的事是动画分层混合。比如角色边跑边挥剑:主轨放全身跑步 clip 当基础层,Add Override Track 加一条子轨放挥剑 clip,选中子轨在 Inspector 勾上 Apply Avatar Mask,指定一个只含上半身骨骼的 AvatarMask:

运行时 AnimationTrack 建出 AnimationLayerMixerPlayable,基础层走 input 0,Override 层走 input 1 并带 Mask,下半身跑步、上半身挥剑自动混合。AnimationTrack 实现 ILayerable 接口就是为了能建 LayerMixer 做分层。
AudioTrack、ActivationTrack 不支持分层,没有 Override 子轨。
所以后面建图时会看到三种情况:
- 有子轨且支持 Layer(AnimationTrack)—— 建 LayerMixer,每层各自编译
- 就自己一条轨(大多数情况)—— 直接编译
- 有子轨但不支持 Layer(理论上不常见)—— 把所有 clip 合并后编译
Attribute 声明能力
轨道通过 Attribute 告诉 Timeline 自己的能力:
TrackClipType—— 这条轨能放哪种 clip。参数是实现了ITimelineClipAsset的 PlayableAsset 类型。TrackBindingType—— 这条轨的 Output 要绑什么类型的目标,对应PlayableBinding的outputTargetType。
比如 AnimationTrack 上标的就是:
// AnimationTrack.cs
[TrackClipType(typeof(AnimationPlayableAsset))]
[TrackBindingType(typeof(Animator))]
public class AnimationTrack : TrackAsset { ... }
这两个 Attribute 在运行时也会被读取。outputs 属性返回 PlayableBinding 时会通过反射拿 TrackBindingType 确定绑定类型;编辑器里右键添加 clip 时也会检查 TrackClipType 来过滤可选类型。
之前自定义轨道时也用过这些 Attribute,还有个 TrackColor 可以设置轨道在编辑器里的颜色:
// 自定义轨道示例 VayneControlSimpleTrack.cs
[TrackColor(0.20f, 0.70f, 0.20f)]
[TrackClipType(typeof(VayneControlSimpleClip))]
[TrackBindingType(typeof(Animator))]
public class VayneControlSimpleTrack : TrackAsset
{
//...
}
TimelineClip:片段数据
classDiagram
direction LR
class TimelineClip {
[Serializable] 片段数据容器
m_Start / m_Duration 时间区间
m_ClipIn / m_TimeScale 偏移与变速
m_EaseInDuration / m_EaseOutDuration
m_BlendInDuration / m_BlendOutDuration
m_MixInCurve / m_MixOutCurve
m_PreExtrapolationMode / m_PostExtrapolationMode
m_Asset 引用的PlayableAsset
m_ParentTrack 所属轨道
clipCaps 从asset取能力
ToLocalTime() 全局时间→clip本地时间
EvaluateMixIn() / EvaluateMixOut()
}
class ITimelineClipAsset {
<>
clipCaps ClipCaps
}
TimelineClip "1" o-- "1" Object : m_Asset 引用
TimelineClip ..> ITimelineClipAsset : asset as 取clipCaps
TrackAsset "1" o-- "*" TimelineClip : m_Clips
TimelineClip 不是 Playable,也不继承 PlayableAsset。它就是一个 [Serializable] 的数据容器,存的是”这段 clip 在时间轴上从哪开始、放多久、怎么混合”这些信息。真正建 Playable 节点的是它 m_Asset 字段引用的 PlayableAsset(比如 AnimationPlayableAsset)。
序列化字段
重点关注 m_Asset —— 建图时通过 clip.asset as IPlayableAsset 拿到真正的 PlayableAsset,调它的 CreatePlayable() 创建节点。其余字段都是时间和混合参数:
// TimelineClip.cs
// ---- 时间 ----
[SerializeField] double m_Start; // 时间轴上的起始时间(秒)
[SerializeField] double m_Duration; // 持续时间(秒),end = start + duration
[SerializeField] double m_ClipIn; // 内部偏移,比如 10 秒动画只从第 3 秒开始播就设 3
[SerializeField] double m_TimeScale; // 播放速度,默认 1.0,范围 [0.001, 1000]
// ---- 混合 / 淡入淡出 ----
[SerializeField] double m_EaseInDuration; // 缓入(clip 不重叠时的淡入)
[SerializeField] double m_EaseOutDuration; // 缓出
[SerializeField] double m_BlendInDuration; // 混合入(clip 重叠时的交叉淡化)
[SerializeField] double m_BlendOutDuration; // 混合出
[SerializeField] AnimationCurve m_MixInCurve; // 混合入曲线
[SerializeField] AnimationCurve m_MixOutCurve; // 混合出曲线
// ---- 外推 ----
[SerializeField] ClipExtrapolation m_PreExtrapolationMode; // clip 开始前:None / Hold / Loop / PingPong
[SerializeField] ClipExtrapolation m_PostExtrapolationMode; // clip 结束后
// ---- 资产引用 ----
[SerializeField] Object m_Asset; // 引用的 PlayableAsset
[SerializeField] TrackAsset m_ParentTrack; // 所属轨道
[SerializeField] AnimationClip m_AnimationCurves; // clip 级参数动画曲线(不是 clip asset 自己的)
Ease 和 Blend 容易混——两个 clip 重叠时重叠区域走 Blend(交叉淡化),不重叠时的淡入淡出走 Ease。EvaluateMixIn() / EvaluateMixOut() 按当前时间和曲线算出 [0, 1] 权重,RuntimeClip 拿这个值写 Mixer 的 inputWeight。
外推是 clip 区间外怎么处理。比如走路 clip 结束后设 Hold,角色保持最后姿势不动。
clipCaps
TimelineClip 的能力不是自己定义的,是从 m_Asset 上取的:
// TimelineClip.cs
/// <summary>
/// 附加到片段的 PlayableAsset。
/// </summary>
public Object asset
{
get { return m_Asset; }
set { m_Asset = value; }
}
/// <summary>
/// 返回此片段支持的功能。
/// </summary>
public ClipCaps clipCaps
{
get
{
var clipAsset = asset as ITimelineClipAsset;
return (clipAsset != null) ? clipAsset.clipCaps : kDefaultClipCaps;
}
}
ClipCaps 是 Flags 枚举,有 Looping、Extrapolation、ClipIn、SpeedMultiplier、Blending 等。timeScale 的 setter 里会检查 clipCaps.HasAny(ClipCaps.SpeedMultiplier),asset 不支持变速的话设了也没用。
ToLocalTime:时间转换
每帧拿到的是时间轴全局时间(Director 当前播到第几秒),clip 内部要的是”我自己播到第几秒”,ToLocalTime() 做这个转换。
时间轴上一个 clip 涉及三段区间:
前外推区间 clip 正常区间 后外推区间
|<-- Pre -->|<--- start ~ end --->|<-- Post -->|
↑ m_Start ↑ m_Start + m_Duration
- 正常区间(
start ≤ time ≤ start + duration):clip 正在播的部分 - 前外推(
time < start,开了 Pre-Extrapolation):clip 还没开始但要有表现,比如 Hold 第一帧 - 后外推(
time > end,开了 Post-Extrapolation):clip 结束后仍要表现,比如 Hold 最后一帧、Loop 循环
转换分两步走:
// TimelineClip.cs
public double ToLocalTime(double time)
{
// 负时间直接返回,不做转换
if (time < 0)
return time;
// ---------- 第一步:按所处区间做时间映射 ----------
// 三个分支都是先 time - m_Start 转成相对时间,
// 外推分支再按模式(Loop/Hold/PingPong)做额外换算
if (IsPreExtrapolatedTime(time))
// 前外推:比如 Loop 模式下对 duration 取模,Hold 模式下 clamp 到 0
time = GetExtrapolatedTime(time - m_Start, m_PreExtrapolationMode, m_Duration);
else if (IsPostExtrapolatedTime(time))
// 后外推:同理,按后外推模式换算
time = GetExtrapolatedTime(time - m_Start, m_PostExtrapolationMode, m_Duration);
else
// 正常区间:全局 5.3s,clip 从 3s 开始 → 相对时间 2.3s
time -= m_Start;
// ---------- 第二步:统一应用变速和偏移 ----------
// 注意这两行在 if/else 外面,三个分支都会走到
// clipIn:内部偏移,比如 10s 动画只从第 3s 开始播,clipIn = 3
// timeScale:变速,2x 就是两倍速
time *= timeScale;
time += clipIn;
return time;
}
公式:**localTime = mappedTime * timeScale + clipIn**
RuntimeClip 的 EvaluateAt() 内部调这个方法,拿到 localTime 设给对应 Playable 节点的 time。
和 RuntimeClip 的关系
建图时 CompileClips 会把每个 TimelineClip 和它 asset 创建出来的 Playable 节点一起包成 RuntimeClip,塞进 IntervalTree:
graph LR A["TimelineClip
start / duration / 混合曲线"] --> C["RuntimeClip"] B["clip.asset.CreatePlayable()
Playable 节点"] --> C C --> D["IntervalTree"] D --> E["PrepareFrame
按时间查询 → 激活/写权重"]
简单说就是 TimelineClip 出时间参数,clip asset 出 Playable 节点,RuntimeClip 把两者合到一起变成运行时可以按时间激活和写权重的对象。CompileClips 和 PrepareFrame 后面展开。
PlayableBinding 与绑定机制
前面提到 TrackBindingType 声明了绑定类型,那运行时怎么用起来的?TrackAsset 重写了 outputs 属性,在这里通过反射读 Attribute、转成 PlayableBinding:
// TrackAsset.cs
/// <summary>
/// 返回此轨道将创建的 PlayableOutputs 的描述。
/// </summary>
public override IEnumerable<PlayableBinding> outputs
{
get
{
// 1. 反射取 TrackBindingTypeAttribute,带缓存避免重复反射
TrackBindingTypeAttribute attribute;
if (!s_TrackBindingTypeAttributeCache.TryGetValue(GetType(), out attribute))
{
// GetCustomAttribute:C# 反射 API,从类型上取指定 Attribute
attribute = (TrackBindingTypeAttribute)Attribute.GetCustomAttribute(
GetType(), typeof(TrackBindingTypeAttribute));
// 存进字典,下次直接查表
s_TrackBindingTypeAttributeCache.Add(GetType(), attribute);
}
// 2. 拿到绑定类型(比如 AnimationTrack → Animator,AudioTrack → AudioSource)
// 如果轨道没标 TrackBindingType,trackBindingType 为 null
var trackBindingType = attribute != null ? attribute.type : null;
// 3. 创建 PlayableBinding
// ScriptPlayableBinding.Create 是 Unity 引擎 API,
// 把 (轨道名, 轨道自身, 绑定类型) 打包成一个 PlayableBinding 结构体
yield return ScriptPlayableBinding.Create(name, this, trackBindingType);
}
}
PlayableBinding结构体在《Playable源码浅析 - 10.2 资源层》里有介绍,Director 怎么通过它做绑定在《PlayableDirector - 运行时绑定》里展开过,这里只关注轨道怎么产出它。
ScriptPlayableBinding.Create(name, this, trackBindingType) 打包出来的 PlayableBinding 带三个关键信息:
sourceObject— 传的是this(Track 自身),Director 绑定表拿它当 keyoutputTargetType— 从TrackBindingTypeAttribute 读出来的类型,比如 AnimationTrack 对应Animator,AudioTrack 对应AudioSourcestreamName— track 的name,就是 Inspector 绑定槽上显示的那个名字
TimelineAsset.outputs 遍历 GetOutputTracks(),把每条 track 的 outputs 拼到一起。Director 读这个列表就知道要显示几个绑定槽、每个槽接受什么类型。
PlayableDirector 怎么存绑定
绑定关系不在 .playable 资产里,存在 PlayableDirector 组件上,key-value 结构:
graph LR A["TrackAsset
(sourceObject / key)"] -->|"SetGenericBinding"| B["PlayableDirector
SceneBindings"] B --> C["目标对象
Animator / AudioSource 等
(target / value)"]
key 是 Track(PlayableBinding.sourceObject),value 是场景里的目标对象。所以从 Project 窗口双击 .playable 打开时 track 左边看不到绑定——绑定不在资产文件里,得从挂了 Director 的 GameObject 点进去才能在 Inspector 上看到 Bindings 列表。
建图时 CreateTrackOutput() 创建 PlayableOutput,通过 Director.GetGenericBinding(track) 取出目标对象设成 output 的 target,数据流就通了。
绑定 API 用法(
SetGenericBinding/GetGenericBinding、遍历outputs按类型绑定等)在《PlayableDirector - 运行时绑定》里有完整示例,不再重复。
MarkerTrack 与 Marker
clip 管的是一段时间区间内的表现,Marker 管的是某个时间点上的事件。用得最多的就是 Signal——在某一帧触发一个回调。
classDiagram
direction LR
IMarker <|.. Marker : 实现
ScriptableObject <|-- Marker : 继承
Marker <|-- SignalEmitter
INotification <|.. SignalEmitter : 实现
INotificationOptionProvider <|.. SignalEmitter : 实现
class IMarker {
<>
time double
parent TrackAsset
}
class Marker {
<>
m_Time double
}
class SignalEmitter {
m_Asset SignalAsset
m_Retroactive bool
m_EmitOnce bool
}
class INotification {
<>
id PropertyName
}
class INotificationOptionProvider {
<>
flags NotificationFlags
}
IMarker 接口就两个属性:time(时间点)和 parent(所属轨道)。Marker 继承 ScriptableObject、实现 IMarker,是抽象基类,自定义 Marker 继承它就行。
SignalEmitter 是 Timeline 内置的信号发射器,类图里能看到它同时实现了两个接口:
INotification— 有这个接口的 Marker 才会被CreateNotificationsPlayable收集、编译成运行时的通知节点。不是所有 Marker 都会产生通知,自定义 Marker 不实现INotification的话运行时直接跳过。INotificationOptionProvider— 提供额外的触发选项:retroactive(时间轴跳过了这个时间点,要不要补发)、emitOnce(是否只触发一次)。
工作流程:SignalEmitter 在指定时间点发出 Signal,场景里挂了 SignalReceiver 的 GameObject 接收后触发对应的 UnityEvent。
Marker 存在哪
每条 TrackAsset 内部都有 m_Markers(MarkerList),任何轨道都能放 Marker。但 Timeline 还有一条单独的隐藏轨道 MarkerTrack,专门放全局 Marker(编辑器里时间轴最上方那一行就是它):
// TimelineAsset.cs
[HideInInspector, SerializeField] MarkerTrack m_MarkerTrack;
public void CreateMarkerTrack()
{
if (m_MarkerTrack == null)
{
m_MarkerTrack = CreateInstance<MarkerTrack>();
TimelineCreateUtilities.SaveAssetIntoObject(m_MarkerTrack, this);
m_MarkerTrack.parent = this;
m_MarkerTrack.name = "Markers"; // 含 Signal 时会显示在绑定列表中
Invalidate();
}
}
m_MarkerTrack 不在 m_Tracks 列表里,单独序列化。GetRootTracks() 会把它插到结果最前面;GetOutputTracks() 则只在它含有 INotification 类型的 Marker 时才把它算作 output track——没有 Signal 的 MarkerTrack 不会产生 PlayableOutput。
MarkerTrack 的 outputs 有点特殊:如果是 TimelineAsset 顶层的那条 markerTrack,绑定类型固定为 GameObject(Director 所在的 GameObject),SignalReceiver 也挂在这个对象上;其他情况走 TrackBindingType 常规逻辑。
小结
graph TD A["TimelineAsset
.playable 根资产"] -->|"m_Tracks 持有"| B["TrackAsset
AnimationTrack / AudioTrack / ..."] A -->|"m_MarkerTrack 单独序列化"| M["MarkerTrack
隐藏的全局 Marker 轨"] B -->|"m_Clips 列表"| C["TimelineClip
start / duration / blend"] B -->|"m_Children 列表"| B2["子轨 SubTrack
Override 轨等"] C -->|"m_Asset 引用"| D["PlayableAsset
AnimationPlayableAsset 等"] B -->|"outputs 属性产出"| E["PlayableBinding
绑定槽描述"] E -->|"sourceObject 当 key"| F["PlayableDirector
存 key→target 绑定关系"] M -->|"m_Markers 列表"| M2["Marker / SignalEmitter"]
TimelineAsset 是根,通过 m_Tracks 持有轨道、m_MarkerTrack 持有全局 Marker 轨;TrackAsset 通过 m_Clips 存片段、m_Children 存子轨,outputs 产出绑定槽描述;TimelineClip 存时间参数,m_Asset 指向真正的 PlayableAsset;PlayableDirector 拿 Track 当 key 存绑定目标。这些数据本身不参与运行时评估,建图时被遍历、编译成 PlayableGraph 节点。
4.3 Timeline 运行时建图
静态结构搞清楚了,现在看运行时——资产树怎么变成 PlayableGraph 里的节点。
先有个直观印象。下面这个 Timeline 涵盖了常见轨道类型:Activation(控制 Sphere 显隐)、Animation(Vayne 的 Idle/Run)、Audio(sample-3s 音频)、两条 Control(一条控粒子系统、一条嵌套子 Timeline)、Signal(挂了 SignalReceiver)、自定义 Playable(ChangeObjPosPlayableAsset),底下还有一个带 Override 子轨的 Track Group:

Play 之后用 PlayableGraph Visualizer 抓到的运行时节点图:

对照着看——右侧 8 个彩色方块是 PlayableOutput,每个对应一条 output track(#0 Activation、#1 Animation、#2 Audio……#7 底下那条带 Override 的 Animation);中间绿色的 TimelinePlayable 是根节点,8 个 input 口各接一条轨道的子树;往左是各轨道的 Mixer 节点,最左边是叶子节点(AnimationClipPlayable、AudioClipPlayable、ActivationControlPlayable 等)。底部那条 Animation 轨因为有 Override 子轨,多了一层 AnimationLayerMixerPlayable。下面拆开看这张图怎么建出来的。
整体调用链
PlayableDirector.Play() / RebuildGraph() 在 C++ 层创建 PlayableGraph,回调 C# 的 TimelineAsset.CreatePlayable():
graph TD A["Director.Play() / RebuildGraph()
C++ 层建空 Graph,回调 C#"] -->|"playableAsset.CreatePlayable()"| B["TimelineAsset.CreatePlayable()
取 GetOutputTracks(),传给 Create"] B -->|"传入 output track 列表"| C["TimelinePlayable.Create()
建根 ScriptPlayable(Passthrough 模式)"] C -->|"根节点建好,开始编译"| D["TimelinePlayable.Compile()
初始化 IntervalTree、ActiveClips 等容器"] D -->|"遍历每条 output track"| E["CompileTrackList()
跳过不可编译的轨道(如 GroupTrack)"] E -->|"逐条轨道"| F["CreateTrackPlayable()
递归确保父轨先编译,再建子树并接入图"] F -->|"调轨道自己的建图方法"| G["track.CreatePlayableGraph()"] G -->|"编译 clip 建 Mixer"| H["CreateMixerPlayableGraph()
建 Mixer + 各 clip 叶子节点"] G -->|"收集 INotification Marker"| I["CreateNotificationsPlayable()
有 Signal 时建 TimeNotificationBehaviour"]
一个 Director 对应一张 PlayableGraph,同一份 Timeline 资产的所有 output track 编译进同一张图。
TimelinePlayable:图的根节点
TimelineAsset.CreatePlayable() 前面 4.2 已经看过,拿 GetOutputTracks() 的结果传给 TimelinePlayable.Create()。
TimelinePlayable 继承 PlayableBehaviour,挂在图的根 ScriptPlayable 上——Visualizer 里中间那个绿色节点就是它。Create() 建出根节点后立刻调 Compile(),Compile() 内部先初始化几个运行时容器(IntervalTree、ActiveClips、PlayableCache),然后调 CompileTrackList() 遍历 output track 列表逐条编译。
整个过程可以理解为:Create() 负责把根节点造出来,Compile() 负责把”空壳”填满——把每条轨道的 Mixer、clip 节点、Output 都建好接进图里。
// TimelinePlayable.cs
/// <summary>
/// 创建 Timeline 的实例
/// 编译轨道列表并创建时间轴可播放项
/// </summary>
/// <param name="graph">要注入时间轴的可播放图</param>
/// <param name="tracks">要编译的轨道列表</param>
/// <param name="go">启动编译的 GameObject</param>
/// <param name="autoRebalance">在编辑器中,图是否应考虑更改片段时间的可能性</param>
/// <param name="createOutputs">是否在图中创建 PlayableOutputs</param>
/// <returns>以包含 TimelinePlayable 行为的可播放项为根的子图</returns>
public static ScriptPlayable<TimelinePlayable> Create(PlayableGraph graph,
IEnumerable<TrackAsset> tracks, GameObject go, bool autoRebalance, bool createOutputs)
{
//...
// 建根节点
// ScriptPlayable<T>.Create 会在 graph 里新建一个 Playable,
// 同时 new 一个 TimelinePlayable 实例作为它的 behaviour
var playable = ScriptPlayable<TimelinePlayable>.Create(graph);
// Passthrough 模式:根节点不参与混合计算,只负责把时间和评估请求往下游分发
// 如果设成 Mix 模式,图在评估时会尝试对 input 做混合,这不是根节点该干的事
playable.SetTraversalMode(PlayableTraversalMode.Passthrough);
// 拿到挂在根节点上的 TimelinePlayable behaviour,调 Compile 开始编译整张图
var sequence = playable.GetBehaviour();
sequence.Compile(graph, playable, tracks, go, autoRebalance, createOutputs);
return playable;
}
/// <summary>
/// 编译此时间轴的子图
/// 将轨道列表编译为可播放图结构
/// </summary>
/// <param name="graph">要注入时间轴的可播放图</param>
/// <param name="timelinePlayable">时间轴根可播放项</param>
/// <param name="tracks">要编译的轨道列表</param>
/// <param name="go">启动编译的 GameObject</param>
/// <param name="autoRebalance">在编辑器中,图是否应考虑更改片段时间的可能性</param>
/// <param name="createOutputs">是否在图中创建 PlayableOutputs</param>
public void Compile(PlayableGraph graph, Playable timelinePlayable,
IEnumerable<TrackAsset> tracks, GameObject go, bool autoRebalance, bool createOutputs)
{
// 初始化运行时容器:
// m_IntervalTree — 区间树,CompileClips 时把每个 RuntimeClip 按 [start, end] 塞进去,
// 运行时 PrepareFrame 用它按当前时间查哪些 clip 处于激活区间
// m_ActiveClips — 每帧 IntervalTree 查询结果的缓存列表
// m_PlayableCache — Dictionary<TrackAsset, Playable>,同一条轨道只编译一次,
// Override 子轨递归时可能重复触发父轨,靠这个缓存跳过
//...
CompileTrackList(graph, timelinePlayable, outputTrackList, go, createOutputs);
//...
}
/// <summary>
/// 编译轨道列表
/// </summary>
/// <param name="graph">可播放图</param>
/// <param name="timelinePlayable">时间轴可播放项</param>
/// <param name="tracks">轨道集合</param>
/// <param name="go">游戏对象</param>
/// <param name="createOutputs">是否创建输出</param>
private void CompileTrackList(PlayableGraph graph, Playable timelinePlayable,
IEnumerable<TrackAsset> tracks, GameObject go, bool createOutputs)
{
foreach (var track in tracks)
{
// IsCompilable() 检查轨道是否需要编译:
// GroupTrack 只是分组容器,返回 false
// muted 的轨道也返回 false(静音就不编译了)
// 没有 clip 也没有子轨也没有 Marker 的空轨道同样 false
if (!track.IsCompilable()) continue;
// 缓存检查:同一条轨道只编译一次
// 场景:output track 列表里有 Override 子轨 A,
// A 编译时递归先编译了父轨 B,后面遍历到 B 时就不需要再编译了
if (!m_PlayableCache.ContainsKey(track))
{
// clip 按 m_Start 排序
// IntervalTree 内部 Rebuild 时依赖 clip 有序,乱序会导致查询结果不对
track.SortClips();
CreateTrackPlayable(graph, timelinePlayable, track, go, createOutputs);
}
}
}
CreateTrackPlayable() 对每条轨道做四步:
- 确定父节点 — output track 列表里可能有 Override 子轨,子轨要连到父轨的 LayerMixer 上而不是根节点上,所以先判断
track.parent是不是 TrackAsset:是的话递归编译父轨(缓存里有就直接拿),不是的话说明是普通顶层轨道,父节点就是根 TimelinePlayable - 调
track.CreatePlayableGraph()建子树 — 走CreateMixerPlayableGraph→CompileClips链路,建出 Mixer + 各 clip 叶子节点,同时把 RuntimeClip 塞进 IntervalTree graph.Connect()接入图 — 父节点动态扩一个 input 口,把子树接上去,初始权重 1.0CreateTrackOutput()建 PlayableOutput — 为这条轨道建一个 Output,负责把数据输出给绑定的 Animator / AudioSource 等
// TimelinePlayable.cs
/// <summary>
/// 创建轨道可播放项
/// </summary>
/// <param name="graph">可播放图</param>
/// <param name="timelinePlayable">时间轴可播放项</param>
/// <param name="track">轨道资源</param>
/// <param name="go">游戏对象</param>
/// <param name="createOutputs">是否创建输出</param>
/// <returns>可播放项</returns>
Playable CreateTrackPlayable(PlayableGraph graph, Playable timelinePlayable,
TrackAsset track, GameObject go, bool createOutputs)
{
//... 缓存检查(m_PlayableCache 里有就直接返回)、不可编译检查 ...
// ---- 1. 确定父节点 ----
// track.parent 的类型决定了这条轨道接到哪里:
// - Override 子轨:parent 是父 AnimationTrack(TrackAsset),递归编译父轨拿到 LayerMixer
// - 普通轨道:parent 是 TimelineAsset(不是 TrackAsset),as TrackAsset 为 null,
// 走 else 分支直接挂到根节点 timelinePlayable 上
TrackAsset parentActor = track.parent as TrackAsset;
var parentPlayable = parentActor != null
? CreateTrackPlayable(graph, timelinePlayable, parentActor, go, createOutputs)
: timelinePlayable;
// ---- 2. 建这条轨道的子树 ----
// CreatePlayableGraph 内部做两件事:
// a) CreateMixerPlayableGraph → CompileClips:建 Mixer + clip 叶子节点,RuntimeClip 塞进 IntervalTree
// b) CreateNotificationsPlayable:收集 INotification Marker,有的话建 TimeNotificationBehaviour
// m_IntervalTree 传进去,所有轨道的 RuntimeClip 共享同一棵树
var actorPlayable = track.CreatePlayableGraph(graph, go, m_IntervalTree, timelinePlayable);
//...
// ---- 3. 接入图 ----
// PlayableGraph 的 input 口是动态扩展的:先 GetInputCount 拿到当前口数,+1 扩容,
// 然后 Connect 把子树的 output 0 连到父节点的新 input 口上
// 权重设 1.0 表示这条轨道默认生效(后续 PrepareFrame 会按需调整 clip 级别的权重)
int port = parentPlayable.GetInputCount();
parentPlayable.SetInputCount(port + 1);
graph.Connect(actorPlayable, 0, parentPlayable, port);
parentPlayable.SetInputWeight(port, 1.0f);
// ---- 4. 建 PlayableOutput ----
// 两个条件都满足才建:
// createOutputs — 根 Timeline 传 true;嵌套子 Timeline 传 false(子 Timeline 的 Output 由父图管)
// connected — 上面 CreatePlayableGraph 成功了才算 connected,建图失败就没必要建 Output
if (createOutputs && connected)
CreateTrackOutput(graph, track, go, parentPlayable, port);
// 存进缓存,key 是 track,value 包含 Playable、port、parentPlayable
// 后续同条轨道再被触发(比如父轨递归到它)时直接查缓存返回
CacheTrack(track, actorPlayable, port, parentPlayable);
return actorPlayable;
}
TrackAsset.CreatePlayableGraph:轨道建图入口
CreateTrackPlayable() 里调到的 track.CreatePlayableGraph() 内部分两路走:
CreateMixerPlayableGraph()— 编译 clip,建 Mixer + 各 clip 叶子节点,RuntimeClip 塞进 IntervalTreeCreateNotificationsPlayable()— 收集轨道上实现了INotification的 Marker(比如 SignalEmitter),有的话建一个TimeNotificationBehaviour节点
这两路职责完全不同:Mixer 那边负责混合播放数据(动画权重、音频混音),Notification 负责在时间点触发事件回调。源码把它们分开编译,最后决定返回谁。
// TrackAsset.cs
/// <summary>
/// 创建可播放图
/// </summary>
/// <param name="graph">可播放图</param>
/// <param name="go">游戏对象</param>
/// <param name="tree">运行时元素区间树</param>
/// <param name="timelinePlayable">时间轴可播放项</param>
/// <returns>创建的可播放项</returns>
internal Playable CreatePlayableGraph(PlayableGraph graph, GameObject go,
IntervalTree<RuntimeElement> tree, Playable timelinePlayable)
{
// 先更新轨道 duration(遍历 clip 取最大结束时间)
UpdateDuration();
// ---- 第一路:编译 clip ----
// CanCreateMixerRecursive() 检查自身和子轨有没有可编译的 clip
// 空轨道(比如只放了 Marker 没放 clip 的 Signal 轨)这里返回 false,跳过
var mixerPlayable = Playable.Null;
if (CanCreateMixerRecursive())
mixerPlayable = CreateMixerPlayableGraph(graph, go, tree);
// ---- 第二路:编译 Notification ----
// mixerPlayable 传进去:如果两路都有效,notification 节点会把 mixer 连为自己的 input
// 这样数据流是 clip 节点 → Mixer → NotificationBehaviour → 父节点
Playable notificationsPlayable = CreateNotificationsPlayable(
graph, mixerPlayable, go, timelinePlayable);
//...
// ---- 决定返回谁 ----
// notification 节点包裹了 mixer,所以有 notification 时返回它(它是最外层)
// 没有 notification 时直接返回 mixer
return notificationsPlayable.IsValid() ? notificationsPlayable : mixerPlayable;
}
notificationsPlayable.IsValid() ? notificationsPlayable : mixerPlayable背后的逻辑:
CreateNotificationsPlayable() 内部先调 GatherNotifications() 收集自身和子轨的所有 Marker,交给 NotificationUtilities 遍历。遍历时只关心实现了 INotification 接口的 Marker——前面 4.2 提过,不是所有 Marker 都是 Notification,自定义 Marker 如果没实现 INotification 就会被跳过。只有碰到至少一个 INotification,才会真正 ScriptPlayable<TimeNotificationBehaviour>.Create() 建出通知节点,否则返回 Playable.Null。
IsValid()的底层:Playable结构体持有PlayableHandle,IsValid()调的是PlayableHandle.IsValid()——一个extern方法,走 C++ 引擎层做 handle 校验(index + version 比对)。Playable.Null的 handle 无效,所以IsValid()返回 false。
三种返回情况:
- 有 clip 也有 Notification Marker(比如 Signal 轨同时有 clip)→ notification 节点有效,mixer 连在它下面,返回 notification 节点作为这条轨道的最外层
- 有 clip 但没有 Notification Marker(大部分轨道的常见情况)→ notification 节点无效,直接返回 mixer
- 两者都无效(既没可编译 clip 也没 INotification Marker)→ 理论上不该走到这,源码里打 Error Log 返回空 Playable 占位
CreateMixerPlayableGraph:子轨怎么分层
GatherCompilableTracks() 先收集要编译的轨道——自身加上 m_Children 里可编译的子轨,汇成一个列表。然后根据轨道类型分三条路走:
graph TD A["CreateMixerPlayableGraph()"] -->|"收集自身 + m_Children 子轨"| B["GatherCompilableTracks()
结果存进 s_BuildData.trackList"] B -->|"trackList 为空 → 返回 Null"| X["Playable.Null
(空轨道,没东西可编译)"] B --> C{"this as ILayerable != null ?
即:轨道是否支持分层混合"} C -->|"是(AnimationTrack)"| D["CreateLayerMixer()
建 AnimationLayerMixerPlayable"] D -->|"遍历 trackList 每条轨"| E["各自 CompileClips()
建出各自的 Mixer + clip 节点"] E -->|"Connect 到 LayerMixer 的第 i 个 input"| D C -->|"否"| F{"trackList.Count == 1 ?
只有自身,没子轨"} F -->|"是(大部分轨道走这)"| G["直接 CompileClips()
建 Mixer + clip 节点"] F -->|"否(有子轨但不支持 Layer)"| H["合并所有子轨的 clip 列表
当作一条轨 CompileClips()"]
对应到 Visualizer 截图:上面那条不带 Override 的 Animation 轨走的是情况 2(单条轨直接 CompileClips),底下 Track Group (1) 里带 Override 的 Animation 轨走的是情况 1——能看到多了一层 AnimationLayerMixerPlayable,基础层和 Override 层各自有自己的 AnimationMixerPlayable,接到 LayerMixer 的 input 0 和 input 1 上。
源码(精简):
// TrackAsset.cs
/// <summary>
/// 创建混合器可播放图
/// </summary>
/// <param name="graph">可播放图</param>
/// <param name="go">游戏对象</param>
/// <param name="tree">运行时元素区间树</param>
/// <returns>混合器可播放项</returns>
internal virtual Playable CreateMixerPlayableGraph(PlayableGraph graph,
GameObject go, IntervalTree<RuntimeElement> tree)
{
//...
// 收集可编译轨道:自身 + m_Children 里 IsCompilable() 的子轨
// s_BuildData 是 static 临时变量,避免每次 new List 产生 GC
s_BuildData.Clear();
GatherCompilableTracks(s_BuildData.trackList);
if (s_BuildData.trackList.Count == 0) return Playable.Null; // 空轨道
// ---- 情况1:支持分层(AnimationTrack 实现了 ILayerable)----
// 尝试 as ILayerable,能转成功说明这条轨支持 Layer 混合
ILayerable layerable = this as ILayerable;
Playable layerMixer = Playable.Null;
if (layerable != null)
// AnimationTrack 的 CreateLayerMixer 返回 AnimationLayerMixerPlayable
// 参数 trackList.Count 决定 input 口数(基础层 + N 条 Override 子轨)
layerMixer = layerable.CreateLayerMixer(graph, go, s_BuildData.trackList.Count);
if (layerMixer.IsValid())
{
// 每条轨(基础层 i=0,Override 子轨 i=1,2...)各自编译
// 各自建出自己的 Mixer + clip 节点子树
for (int i = 0; i < s_BuildData.trackList.Count; i++)
{
var mixer = s_BuildData.trackList[i].CompileClips(
graph, go, s_BuildData.trackList[i].clips, tree);
if (mixer.IsValid())
{
// 接到 LayerMixer 的第 i 个 input 上
// 运行时 LayerMixer 根据 AvatarMask 和权重做分层混合
graph.Connect(mixer, 0, layerMixer, i);
layerMixer.SetInputWeight(i, 1.0f);
}
}
return layerMixer;
}
// ---- 情况2:只有一条轨(大部分轨道走这,没有子轨)----
// 直接编译自身的 clip,不需要额外的 LayerMixer 包装
if (s_BuildData.trackList.Count == 1)
return s_BuildData.trackList[0].CompileClips(
graph, go, s_BuildData.trackList[0].clips, tree);
// ---- 情况3:有子轨但不支持 Layer(理论上很少见)----
// 把所有子轨的 clip 合并成一个列表,当成一条轨来编译
// 所有 clip 共享同一个 Mixer,按时间顺序混合
for (int i = 0; i < s_BuildData.trackList.Count; i++)
s_BuildData.clipList.AddRange(s_BuildData.trackList[i].clips);
return CompileClips(graph, go, s_BuildData.clipList, tree);
}
CompileClips:clip 编译成节点
不管上面走哪条分支,最终都进 CompileClips()——这里才是真正把 TimelineClip 变成 Playable 节点的地方。
graph TD A["CompileClips()"] -->|"建这条轨的混合器节点"| B["CreateTrackMixer()
AnimationTrack → AnimationMixerPlayable
AudioTrack → AudioMixerPlayable
默认 → 通用 Playable"] B -->|"遍历 timelineClips 列表"| C["for 每个 TimelineClip"] C -->|"clip.asset as IPlayableAsset
调 asset.CreatePlayable()"| D["CreatePlayable(graph, go, clip)
建叶子节点
如 AnimationClipPlayable"] D -->|"TimelineClip + Playable 打包"| E["new RuntimeClip()
记住 clip 的时间区间和对应的节点"] E -->|"按 [start, end] 区间注册"| F["tree.Add(runtimeClip)
塞进 IntervalTree"] F -->|"叶子节点接到 Mixer 的第 c 个 input"| G["graph.Connect(source, 0, blend, c)"] G -->|"建图时所有 clip 都是静默的"| H["SetInputWeight(c, 0)
权重等运行时 PrepareFrame 来写"] H -->|"下一个 clip"| C
// TrackAsset.cs
/// <summary>
/// 编译片段
/// </summary>
/// <param name="graph">可播放图</param>
/// <param name="go">游戏对象</param>
/// <param name="timelineClips">时间轴片段列表</param>
/// <param name="tree">运行时元素区间树</param>
/// <returns>编译后的可播放项</returns>
internal virtual Playable CompileClips(PlayableGraph graph, GameObject go,
IList<TimelineClip> timelineClips, IntervalTree<RuntimeElement> tree)
{
// 建 Mixer 节点,input 口数 = clip 数量
// CreateTrackMixer 是 virtual:
// AnimationTrack 重写 → AnimationMixerPlayable.Create(graph, clipCount)
// AudioTrack 重写 → AudioMixerPlayable.Create(graph, clipCount)
// 自定义轨道也在这里重写,控制混合逻辑
// 默认实现 → Playable.Create(graph, clipCount),通用占位
var blend = CreateTrackMixer(graph, go, timelineClips.Count);
for (var c = 0; c < timelineClips.Count; c++)
{
// CreatePlayable(graph, go, clip) 是三参数的 protected virtual 版本
// 内部做的事:
// 1. clip.asset as IPlayableAsset 拿到真正的 PlayableAsset
// (如 AnimationPlayableAsset、AudioPlayableAsset)
// 2. 调 asset.CreatePlayable(graph, go) 建叶子节点
// (如 AnimationClipPlayable、AudioClipPlayable)
// 3. 如果 clip 有参数动画曲线(m_AnimationCurves),也一并设上去
var source = CreatePlayable(graph, go, timelineClips[c]);
if (source.IsValid())
{
// 把 clip 的 duration 设给叶子节点,图评估时用
source.SetDuration(timelineClips[c].duration);
// RuntimeClip 把 TimelineClip(时间参数)和 Playable(节点)打包在一起
// 它实现了 IInterval 接口,interval = [clip.start, clip.end]
// PrepareFrame 时 IntervalTree 按当前时间查出活跃的 RuntimeClip,
// 再调 RuntimeClip.EvaluateAt() 写权重、设 localTime
var clip = new RuntimeClip(timelineClips[c], source, blend);
// 注册进 IntervalTree,所有轨道的 RuntimeClip 共享同一棵树
tree.Add(clip);
// 叶子节点接到 Mixer 的第 c 个 input 上
graph.Connect(source, 0, blend, c);
// 关键:初始权重设 0,建图时所有 clip 都是"静默"的
// 权重不在这里写,而是运行时每帧由 PrepareFrame() 根据
// IntervalTree 查询结果来决定——当前时间在哪个 clip 区间内,
// 哪个 clip 的权重就从 0 变成具体值(通过 EvaluateMixIn/Out 算出)
blend.SetInputWeight(c, 0.0f);
}
}
// 处理轨道级动画曲线(m_Curves),如果有的话也注册进 tree
ConfigureTrackAnimation(tree, go, blend);
return blend;
}
这里有个容易混淆的点:TrackAsset 继承了 PlayableAsset,按理有个两参数的 CreatePlayable(graph, go),但源码里这个方法被 sealed override 直接返回 Playable.Null。轨道建图走的是 CreatePlayableGraph → CompileClips 这条链路,不走 PlayableAsset 那套 override。真正调 CreatePlayable() 建叶子节点的是 clip 的 asset(AnimationPlayableAsset.CreatePlayable() 之类的),别搞混了。
到这里建图就完成了:每个 clip 被包成 RuntimeClip 塞进 IntervalTree,运行时 PrepareFrame() 每帧用当前时间去 IntervalTree 里查,查出哪些 clip 处于激活区间,给它们写上权重让 Mixer 混合出最终结果。
CreateTrackOutput:建 PlayableOutput
节点子树建完后,CreateTrackPlayable 还会调 CreateTrackOutput() 建 PlayableOutput。4.2 里讲的 PlayableBinding 在这里真正用上了——从 binding 里拿到类型信息建出 Output,接到图上,Visualizer 右侧那些彩色方块就是这么来的。
// TimelinePlayable.cs
void CreateTrackOutput(PlayableGraph graph, TrackAsset track,
GameObject go, Playable playable, int port)
{
// Override 子轨不单独建 Output
// 子轨的数据通过 LayerMixer 汇到父轨,父轨统一出一个 Output
if (track.isSubTrack) return;
// track.outputs 就是 4.2 讲的那个 outputs 属性
// 内部通过反射读 TrackBindingType Attribute,yield return 一个 PlayableBinding
var bindings = track.outputs;
foreach (var binding in bindings)
{
// binding.CreateOutput(graph) 根据 PlayableBinding 里存的 createFunction 委托
// 建出对应类型的 PlayableOutput:
// AnimationTrack → AnimationPlayableOutput
// AudioTrack → AudioPlayableOutput
// 其他 → ScriptPlayableOutput
var playableOutput = binding.CreateOutput(graph);
// ReferenceObject 设成 Track 自身(binding.sourceObject = this)
// C++ 层拿这个当 key 去 Director 的绑定表里查对应的 target(Animator/AudioSource 等)
playableOutput.SetReferenceObject(binding.sourceObject);
// 告诉 Output 从图的哪个节点的哪个 port 读数据
// playable 是父节点(通常是根 TimelinePlayable),port 是这条轨道接入的 input 口号
playableOutput.SetSourcePlayable(playable, port);
playableOutput.SetWeight(1.0f);
// AnimationTrack 特殊处理
if (track as AnimationTrack != null)
// 注册 AnimationOutputWeightProcessor 回调
// 这个 Processor 做几件事:
// 1. 把 AnimationPlayableOutput 的初始权重设为 0(其他类型默认 1)
// 2. 运行时每帧 Evaluate() 里重新算动画权重:
// Mixer 输入权重归一化、Layer 权重处理等
// 之所以单独处理,是因为动画混合对权重的要求比较严格,
// 多个 clip 的权重加起来需要归一化,不能像 Audio 那样简单叠加
EvaluateWeightsForAnimationPlayableOutput(track, (AnimationPlayableOutput)playableOutput);
//...
}
}
有一个容易踩坑的地方:PlayableOutput 的 target(实际绑定的 Animator / AudioSource 等)不是在 C# 这里设的。CreateTrackOutput 全程没有调 SetTarget(),传入的 target 都是 null。真正设 target 的是 C++ 层——图评估前根据 ReferenceObject(Track)去 Director 的绑定表(SetGenericBinding 存的那个 key-value)里查出目标对象填上去。所以如果运行时发现 Output 没绑上目标,大概率是 Director 上的绑定没设。
小结
回头对照 Visualizer 截图,把图上的每个部分和源码对应起来:
| Visualizer 里看到的 | 源码里谁建的 | 说明 |
|---|---|---|
| 右侧彩色方块(PlayableOutput) | CreateTrackOutput() |
每条 output track 一个,类型由 PlayableBinding 决定(AnimationPlayableOutput / AudioPlayableOutput / ScriptPlayableOutput) |
| 中间绿色 TimelinePlayable | TimelinePlayable.Create() |
图的根节点,Passthrough 模式,8 个 input 口对应 8 条 output track |
| Mixer 节点(AnimationMixerPlayable 等) | CompileClips() → CreateTrackMixer() |
每条轨道一个 Mixer,input 口数 = 轨道上的 clip 数量 |
| 最左侧叶子节点(AnimationClipPlayable 等) | CompileClips() → clip.asset.CreatePlayable() |
每个 TimelineClip 对应一个叶子节点,接到 Mixer 的 input 上 |
| AnimationLayerMixerPlayable | CreateMixerPlayableGraph() → ILayerable.CreateLayerMixer() |
只有带 Override 子轨的 AnimationTrack 才有,基础层和 Override 层各自的 Mixer 接到它的 input 上 |
| TimeNotificationBehaviour | CreateNotificationsPlayable() |
只有轨道上存在 INotification Marker(如 SignalEmitter)时才建 |

到这里整张 PlayableGraph 就建完了。接下来每帧由 TimelinePlayable.PrepareFrame() 驱动——用 IntervalTree 按当前时间查出哪些 clip 处于激活区间,给对应的 Mixer input 写权重让 clip 生效,不在区间内的 clip 权重保持 0。
4.4 Timeline 运行时每帧评估
4.3 走完之后,PlayableGraph 里该有的节点和连线都有了,但所有 clip 对应的叶子节点都是 Paused 状态,Mixer 上每个 input 的 weight 也全是 0。换句话说,图建好了,但还没有任何东西在”播”。
那每帧是谁来决定哪些 clip 该 Play、权重写多少?就是 TimelinePlayable.PrepareFrame()。PlayableGraph 评估时会按前序遍历触发每个节点的 PrepareFrame,TimelinePlayable 作为根节点最先被调到,它内部转给 Evaluate() 方法跑一套完整的流程。整个流程可以概括为四步——查、关、开、后处理:
graph TD A["TimelinePlayable.PrepareFrame() PlayableGraph 前序遍历时触发"] -->|"编辑器下先检查 clip 区间有没有被拖动过 有变化就标 dirty,下次查询会 Rebuild"| B["Evaluate(playable, frameData) 每帧评估的核心入口"] B --> C["① IntervalTree.IntersectsWith(currentTick) 把当前时间(double → Int64 tick)丢进区间树 查出本帧所有处于激活区间的 RuntimeClip"] C --> D["② 对比上帧活跃列表 遍历 m_ActiveClips,intervalBit != m_ActiveBit 的 说明上帧有但这帧没了 → 调 DisableAt() 设最后一帧时间 → Pause + Mixer inputWeight 清零"] D --> E["③ 遍历本帧活跃 clip,逐个调 EvaluateAt() enable=true(Play)→ 按 MixIn×MixOut 算权重 → mixer.SetInputWeight() → ToLocalTime 设时间"] E --> F["④ 跑 m_EvaluateCallbacks AnimationOutputWeightProcessor 做动画权重归一化 防止 Mixer 权重和 < 1 时混入 T-Pose"]
下面逐步展开学习。先看被查询的对象——RuntimeClip 长什么样,再看 IntervalTree 内部是怎么建树和查询的,然后回到 Evaluate() 主循环看它怎么把这些串起来,最后看动画特有的权重后处理。
RuntimeElement 与 RuntimeClip
4.3 的 CompileClips() 里,每个 TimelineClip 都被包成了一个 RuntimeClip 塞进 IntervalTree,但当时没展开这个类的内部结构。在看 PrepareFrame 怎么跑之前,得先搞清楚 RuntimeClip 到底存了什么、继承链长什么样。
classDiagram
direction LR
IInterval <|.. RuntimeElement : 实现接口
RuntimeElement <|-- RuntimeClipBase : 继承
RuntimeClipBase <|-- RuntimeClip : 继承
class IInterval {
<>
+intervalStart : Int64 区间起点(tick)
+intervalEnd : Int64 区间终点(tick)
IntervalTree 查询时用这两个值做区间比较
}
class RuntimeElement {
<>
+intervalBit : int 标记位,Evaluate 用来区分上帧/本帧
+enable : bool(set only)Play 或 Pause 叶子节点
+EvaluateAt(localTime, frameData) 激活时调用,写权重+设时间
+DisableAt(localTime, rootDuration, frameData) 离开区间时调用,Pause+清零
}
class RuntimeClipBase {
<>
+start : double 区间起点(秒,含外推)
+duration : double 区间时长(秒,含外推)
intervalStart = DiscreteTime.GetNearestTick(start)
intervalEnd = DiscreteTime.GetNearestTick(start+duration)
把秒转成 Int64 tick 给 IInterval 用
}
class RuntimeClip {
-m_Clip : TimelineClip 静态数据(start, duration, 混合曲线, ToLocalTime 等)
-m_Playable : Playable 叶子节点(clip.asset.CreatePlayable 建的)
-m_ParentMixer : Playable 这个叶子接入的 Mixer 节点
}
从下往上看这个继承链:
IInterval 是 IntervalTree 做区间查询用的接口,只有两个属性——intervalStart 和 intervalEnd,类型是 Int64(tick 值,不是 double 秒数,后面 DiscreteTime 那节会解释为什么)。
RuntimeElement 实现了 IInterval,同时定义了每帧评估需要的方法和字段:
intervalBit:一个 int 标记位,用来区分”上帧活跃”和”本帧活跃”,后面讲 Evaluate 主循环时会详细说它的妙用enable:只有 setter,设 true 就 Play 叶子节点,设 false 就 Pause + 权重清零EvaluateAt()/DisableAt():分别在 clip 进入和离开激活区间时被调用
RuntimeClipBase 加了 start 和 duration 两个 double 属性(单位是秒),并在 intervalStart / intervalEnd 的 getter 里把它们通过 DiscreteTime.GetNearestTick() 转成 Int64 tick 值,供 IntervalTree 查询。
RuntimeClip 是最终的具体类,持有三样东西:
- **
m_Clip**(TimelineClip)— 就是 4.2 讲的那个静态数据容器,存着 start、duration、mixInCurve、mixOutCurve、ToLocalTime 等时间参数 - **
m_Playable**(Playable)— 对应的叶子节点,4.3 里clip.asset.CreatePlayable()建出来的那个 - **
m_ParentMixer**(Playable)— 这个叶子节点接入的 Mixer,写权重的时候要用它:mixer.SetInputWeight(playable, weight)
这里有个容易忽略的细节,涉及到 clip 的外推(Extrapolation),先回顾一下外推是什么。
做过 Timeline 动画的应该都碰到过这个场景:一段攻击动画 clip 放在 [2s, 5s],播完之后角色突然跳回 T-Pose。原因是 clip 结束后 Mixer 上对应的 input 权重变成 0 了,缺失的权重被默认姿势填上。外推就是 Timeline 解决这个问题的机制——在 clip 的正片前后各延伸出一段”虚拟区间”,让 clip 在正片之外也能保持生效。
选中 clip 后 Inspector 里有 Pre-Extrapolation 和 Post-Extrapolation 两个下拉框,模式包括:
- None:不外推,clip 到点就结束
- Hold:保持首帧/末帧姿势不动,项目里最常用,就是为了防上面说的”播完跳回 T-Pose”
- Loop:clip 内容循环播放
- PingPong:来回播放
- Continue:按动画曲线的斜率自然延伸
对应到编辑器里,clip 前后那段半透明、颜色比正片浅的区域就是外推区间。
注意 Inspector 里只能选外推模式(None/Hold/Loop…),外推时间值(m_PreExtrapolationTime / m_PostExtrapolationTime)是改不了的——它由 Timeline 根据同轨道上 clip 之间的间距自动算出来。具体逻辑在 Extrapolation.CalculateExtrapolationTimes() 里:后外推时间 = 当前 clip 的 end 到后面最近的 clip 的 start 之间的间距;前外推时间 = 当前 clip 的 start 到前面最近的 clip 的 end 之间的间距(第一个 clip 比较特殊,它的前外推时间就是自己的 start,即从时间轴 0s 到 clip 开始的距离)。另外还有个优先级规则:如果当前 clip 的前一个 clip 已经开了后外推,当前 clip 的前外推时间会被设成 0,避免两段外推重叠打架。
搞清楚外推之后再看 RuntimeClipBase 的 start 和 duration——它取的不是 clip.start / clip.duration(正片区间),而是 clip.extrapolatedStart / clip.extrapolatedDuration(把外推也算进去的加宽区间)。看一下源码就知道区别了:
// TimelineClip.cs
public double extrapolatedStart
{
get
{
// 如果开了前外推(Hold/Loop/PingPong/Continue),
// 起点要往前推 m_PreExtrapolationTime 这么多秒
if (m_PreExtrapolationMode != ClipExtrapolation.None)
return m_Start - m_PreExtrapolationTime;
// 没开前外推,起点就是 clip 本身的 start
return m_Start;
}
}
public double extrapolatedDuration
{
get
{
double length = m_Duration;
// 后外推:终点往后延,总长度加 postTime
if (m_PostExtrapolationMode != ClipExtrapolation.None)
length += Math.Min(m_PostExtrapolationTime, kMaxTimeValue);
// 前外推:起点往前挪了,但终点没动,所以总长度也要加 preTime
if (m_PreExtrapolationMode != ClipExtrapolation.None)
length += m_PreExtrapolationTime;
return length;
}
}
看到这里可能会有个疑问:后外推只影响 extrapolatedDuration(终点后移,起点不动,duration 加 postTime),但前外推**同时影响了 extrapolatedStart 和 extrapolatedDuration**——起点前移了,为什么 duration 也要加 preTime?
原因是 extrapolatedDuration 表示的是从 extrapolatedStart 量到区间终点的总长度,不是”往后延伸了多少”。或者说前外推不改变end的时刻,而是因为start时刻往前移动了,改变了duration以保证end时刻。画个图就清楚了:
原始 clip: |=====正片=====|
↑start ↑end = start + duration
只开后外推: |=====正片=====|---后外推---|
↑start ↑新终点
起点没动,终点后移了 postTime
extrapolatedStart = start(不变)
extrapolatedDuration = duration + postTime
只开前外推: |--前外推--|=====正片=====|
↑新起点 ↑end(终点没动)
起点前移了 preTime,终点没变
extrapolatedStart = start - preTime
extrapolatedDuration = duration + preTime
(从新起点量到原终点,距离变成了 duration + preTime)
前后都开: |--前外推--|=====正片=====|---后外推---|
↑新起点 ↑新终点
extrapolatedStart = start - preTime
extrapolatedDuration = duration + preTime + postTime
简单说就是:extrapolatedStart + extrapolatedDuration 永远等于区间的右端点。前外推把左端点往前挪了,右端点没变,所以从新左端点量到右端点的距离(duration)自然变大了。
举个具体的数值例子:
假设一个 clip 放在 [2s, 5s],即 start=2, duration=3
情况 A:没开任何外推(None)
extrapolatedStart = 2 (和 start 一样)
extrapolatedDuration = 3 (和 duration 一样)
→ RuntimeClip 的区间 = [2s, 5s]
情况 B:开了前外推 Hold,前面有 1s 空档(m_PreExtrapolationTime = 1)
extrapolatedStart = 2 - 1 = 1
extrapolatedDuration = 3 + 1 = 4
→ RuntimeClip 的区间 = [1s, 5s]
时间轴走到 1~2s 时,这个 clip 也会被 IntervalTree 查到,
Hold 模式下它会保持第一帧的姿势。end时刻其实仍然是5s。
情况 C:前后都开了外推,前 1s 后 2s(m_PostExtrapolationTime = 2)
extrapolatedStart = 2 - 1 = 1
extrapolatedDuration = 3 + 2 + 1 = 6
→ RuntimeClip 的区间 = [1s, 7s]
clip 实际内容只有 [2s, 5s],但 [1s, 2s] 和 [5s, 7s] 也会被查到,
分别走 Pre/Post Extrapolation 逻辑(Hold 住首尾帧,或 Loop 循环播放等)
为什么 RuntimeClipBase 要用这个加宽区间?因为 IntervalTree 是靠 [intervalStart, intervalEnd) 来判断 clip 是否活跃的。如果只用 clip.start / clip.duration 作为查询区间,那时间走到外推区域时 IntervalTree 根本查不到这个 clip,自然也没人给它写权重,外推就失效了。用 extrapolatedStart / extrapolatedDuration 才能保证外推区间也被覆盖。
下图上半部分是 TimelineClip(静态数据),下半部分是 RuntimeClip(运行时容器),左右两端粉色区域就是前后外推区间。可以看到 RuntimeClip 的区间覆盖了整个外推范围:

enable setter
enable 是 RuntimeClip 上最关键的开关。后面要讲的 EvaluateAt() 进来第一件事就是 enable = true,DisableAt() 最后一件事就是 enable = false。它只有 setter,没有 getter——不是用来读状态的,而是用来触发 Play/Pause 动作的:
// RuntimeClip.cs
public override bool enable
{
set
{
// 设 true:如果当前是 Paused 就切到 Playing
if (value && m_Playable.GetPlayState() != PlayState.Playing)
{
m_Playable.Play(); // 让叶子节点恢复播放
SetTime(m_Clip.clipIn); // 从 clipIn 偏移处开始
// clipIn 就是 4.2 讲的那个"剪头"——
// 比如一段 10s 的动画只用后 7s,clipIn = 3
}
// 设 false:如果当前是 Playing 就切到 Paused,并清权重
else if (!value && m_Playable.GetPlayState() != PlayState.Paused)
{
m_Playable.Pause(); // 暂停叶子节点
// 这一步很关键:Pause 只是停了节点的时间推进,
// 但 Mixer 混合时只看 inputWeight,不看 PlayState。
// 如果不把权重清零,Mixer 还是会把这个已暂停节点的数据混进去,
// 表现出来就是角色卡在某个姿势不消失。
if (m_ParentMixer.IsValid())
m_ParentMixer.SetInputWeight(m_Playable, 0.0f);
}
}
}
enable = false 做了两件事:Pause 节点 + 把 Mixer 上对应 input 的权重清零。这和 4.3 建图时所有 inputWeight 初始为 0 是同一个道理——权重为 0 的 input 在 Mixer 混合时不参与计算,这个 clip 就从混合结果里彻底消失了。
IntervalTree:怎么快速查活跃 clip
PrepareFrame 每帧要回答一个问题:当前时间有哪些 clip 处于激活区间? 最朴素的做法是遍历所有 RuntimeClip 逐个比较 start/end,O(n)。clip 少的时候无所谓,但一个 Timeline 上 clip 多了之后(动画、音频、控制、信号叠加),每帧都线性扫一遍就不划算了。
IntervalTree 用空间换时间,建树 O(n log n),查询 O(log n + k)(k 是结果数),把热路径上的开销降下来。建树只在 dirty 时做(添加 clip 或区间变化后标 dirty),运行时如果不改 clip 就不会重建。
数据结构
IntervalTree 内部就两个列表:
- **
m_Entries**(List<Entry>)— 每个 Entry 对应一个 RuntimeClip,存了三样东西:intervalStart(Int64 tick)、intervalEnd(Int64 tick)、item(RuntimeClip 引用) - **
m_Nodes**(List<IntervalTreeNode>)— 树节点数组。每个节点有 5 个字段:center(Int64,分割点)、first/last(int,这个节点管辖的 Entry 在 m_Entries 里的索引范围)、left/right(int,左右子节点在 m_Nodes 里的索引,没有子节点时为kInvalidNode)
这棵树有个巧妙的设计:不额外复制数据。Rebuild 的时候直接对 m_Entries 做原地 partition(类似快排的分区操作,把 Entry 重新排列),然后每个树节点通过 first / last 记录自己管辖的是 m_Entries 里的哪一段。查询的时候按 center 递归,每层只扫当前节点管辖的那段 Entry,不用管的直接跳过。
结合下面这张图看比较好理解。图里有 9 个 RuntimeClip(Entry 0 ~ Entry 8),纵轴是 Int64 时间轴,中间的虚线是根节点的 center。Rebuild 之后 m_Entries 被重排成了三组:
- 节点 ②(左下角,LeftNode):Entry 0 ~ 2,这三个 clip 的区间完全在 center 左侧,
node2.first=0, node2.last=2 - 节点 ①(中间,根节点):Entry 3 ~ 5,这三个 clip 的区间跨越了 center,
node1.first=3, node1.last=5 - 节点 ③(右上角,rightNode):Entry 6 ~ 8,这三个 clip 的区间完全在 center 右侧,
node3.first=6, node3.last=8
图里还标注了 node2 和 node3 是叶子节点(clip 数 < kMinNodeSize),它们的 center 固定为 Int64.MaxValue(kCenterUnknown),Query 时碰到这个值就不再往下递归了:

对应到图里的 9 个 Entry,Rebuild 之后 m_Entries 列表被原地重排成这样(索引 0 在最下面,8 在最上面):
m_Entries 索引: 管辖节点: 含义:
─────────────────────────────────────────────────────
[8] ─┐
[7] ├─ 节点 ③ intervalStart > center → 完全在右侧
[6] ─┘ node3.first=6, node3.last=8
─────────────────────────────────────────────────────
[5] ─┐
[4] ├─ 节点 ① 跨越 center → 留在当前节点(根节点)
[3] ─┘ node1.first=3, node1.last=5
─────────────────────────────────────────────────────
[2] ─┐
[1] ├─ 节点 ② intervalEnd < center → 完全在左侧
[0] ─┘ node2.first=0, node2.last=2
─────────────────────────────────────────────────────
同一个 m_Entries 列表,不同的节点通过 first / last 各管一段,互不重叠。查询时间 t 时,根节点先扫自己管的 [3, 5],然后看 t 和 center 的关系——t < center 就递归左子节点(节点 ②,扫 [0, 2]),t > center 就递归右子节点(节点 ③,扫 [6, 8]),另一边保证不包含 t,直接跳过。
Rebuild:建树
IntersectsWith() 被调用时,如果 dirty == true 就会先跑 Rebuild 再做查询。Rebuild 的核心思想是递归分治——每一层做两趟 partition 把 Entry 分成”左、中、右”三组,中间的留给当前节点,左右的递归交给子节点处理。
在看源码之前,先用一张图理解 Rebuild 每一层在干什么:
假设当前层要处理 m_Entries[start..end] 这段区间
步骤 1:扫一遍找 min(intervalStart) 和 max(intervalEnd),算 center = (min+max)/2
步骤 2:第一趟 partition —— 按 intervalEnd 分
intervalEnd < center 的挪到左边 (这些 clip 在 center 之前就结束了)
intervalEnd >= center 的留在右边 (这些 clip 至少延伸到了 center)
分界点记为 first
步骤 3:第二趟 partition —— 在右半边里按 intervalStart 分
intervalStart <= center 的留在中间 (起点在 center 左边 + 终点在 center 右边 = 跨越 center)
intervalStart > center 的挪到最右边 (这些 clip 在 center 之后才开始)
分界点记为 last
结果:m_Entries[start..end] 被原地重排成三段:
[start ... first-1] [first ... last] [last+1 ... end]
────────────────── ──────────────── ──────────────────
intervalEnd < center 跨越 center intervalStart > center
这些 clip 在 center (起点≤center 且 这些 clip 在 center
之前就结束了 终点≥center) 之后才开始
↓ ↓ ↓
递归建左子树 当前节点管辖 递归建右子树
node.left node.first/last node.right
用一个具体例子走一遍这个过程。假设有 5 个 clip,时间用整数简化表示:
时间轴:
0 10 20 30 40 50 60 70 80 90 100
| | | | | | | | | | |
A: [10─────30]
B: [20──────────────60]
C: [40──────────────70]
D: [55──────────80]
E: [60──────────90]
↑
center = 50
(min=10, max=90, 取中点)
第一趟 partition(按 intervalEnd 分):
- clip A:intervalEnd = 30 < 50 → 左边(A 在 center 之前就结束了)
- clip B:intervalEnd = 60 >= 50 → 留右边
- clip C:intervalEnd = 70 >= 50 → 留右边
- clip D:intervalEnd = 80 >= 50 → 留右边
- clip E:intervalEnd = 90 >= 50 → 留右边
分完后:[A] | [B, C, D, E],first 指向 B 的位置。
第二趟 partition(在右半边按 intervalStart 分):
- clip B:intervalStart = 20 <= 50 → 留中间(跨越 center)
- clip C:intervalStart = 40 <= 50 → 留中间(跨越 center)
- clip D:intervalStart = 55 > 50 → 挪到右边(D 在 center 之后才开始)
- clip E:intervalStart = 60 > 50 → 挪到右边
分完后:[A] | [B, C] | [D, E],last 指向 C 的位置。
最终 m_Entries 被分成三段:
[A] [B, C] [D, E]
左子树 当前节点 右子树
end < 50 跨越 50 start > 50
然后对 [A] 和 [D, E] 分别递归。A 只有 1 个 Entry,不到 kMinNodeSize(10),直接建叶节点。D、E 也一样。
理解了这个过程之后,对着源码看就清楚了:
// IntervalTree.cs
// 递归建树,处理 m_Entries[start..end] 这段区间
// 返回值是新建节点在 m_Nodes 里的索引
private int Rebuild(int start, int end)
{
IntervalTreeNode intervalTreeNode = new IntervalTreeNode();
// ---- 递归出口:Entry 太少就不继续分了 ----
// kMinNodeSize = 10,少于 10 个 Entry 直接作为叶节点
// 查询时对叶节点做暴力遍历就行,10 个以内的线性扫描比递归开销还小
int count = end - start + 1;
if (count < kMinNodeSize)
{
intervalTreeNode = new IntervalTreeNode()
{
// kCenterUnknown = Int64.MaxValue
// Query 碰到这个值就知道是叶节点,不往下递归了
center = kCenterUnknown,
first = start, last = end, // 叶节点管辖整段 [start, end]
left = kInvalidNode, // kInvalidNode = -1,没有子节点
right = kInvalidNode
};
m_Nodes.Add(intervalTreeNode);
return m_Nodes.Count - 1;
}
// ---- 步骤 1:找 center ----
// 遍历当前段所有 Entry,找到时间轴上的最小起点和最大终点
var min = Int64.MaxValue;
var max = Int64.MinValue;
for (int i = start; i <= end; i++)
{
var o = m_Entries[i];
min = Math.Min(min, o.intervalStart); // 所有 clip 里最早的起点
max = Math.Max(max, o.intervalEnd); // 所有 clip 里最晚的终点
}
// center 取中点,后面用它把 clip 分成三组
var center = (max + min) / 2;
intervalTreeNode.center = center;
// ---- 步骤 2:第一趟 partition(按 intervalEnd 分)----
// 目的:把 intervalEnd < center 的 Entry 换到左边
// 这些 clip 在 center 之前就结束了,不可能跨越 center
// 用的是快排里经典的双指针交换法
int x = start;
int y = end;
while (true)
{
// x 从左往右找第一个 intervalEnd >= center 的(不该在左边的)
while (x <= end && m_Entries[x].intervalEnd < center) x++;
// y 从右往左找第一个 intervalEnd < center 的(应该在左边的)
while (y >= start && m_Entries[y].intervalEnd >= center) y--;
if (x > y) break; // 指针交错,partition 完成
// 交换这两个 Entry 的位置
var nodeX = m_Entries[x];
m_Entries[x] = m_Entries[y];
m_Entries[y] = nodeX;
}
// partition 完后,m_Entries[start..x-1] 全是 intervalEnd < center
// m_Entries[x..end] 全是 intervalEnd >= center
intervalTreeNode.first = x; // 从 x 开始往右,都是"至少延伸到 center"的
// ---- 步骤 3:第二趟 partition(按 intervalStart 分)----
// 在 [x, end] 这段里面再分一次
// 目的:把 intervalStart > center 的 Entry 换到最右边
// 这些 clip 在 center 之后才开始,也不跨越 center
// 剩下的(intervalStart <= center 且 intervalEnd >= center)就是跨越 center 的
y = end;
while (true)
{
// x 从左往右找第一个 intervalStart > center 的
while (x <= end && m_Entries[x].intervalStart <= center) x++;
// y 从右往左找第一个 intervalStart <= center 的
while (y >= start && m_Entries[y].intervalStart > center) y--;
if (x > y) break;
var nodeX = m_Entries[x];
m_Entries[x] = m_Entries[y];
m_Entries[y] = nodeX;
}
// 到 y 为止是跨越 center 的 Entry
intervalTreeNode.last = y;
// ---- 步骤 4:递归建子树 ----
// 先在 m_Nodes 里占个位(因为递归过程中 m_Nodes 会不断 Add,
// 如果不先占位,递归返回后 index 就对不上了)
m_Nodes.Add(new IntervalTreeNode());
int index = m_Nodes.Count - 1;
intervalTreeNode.left = kInvalidNode;
intervalTreeNode.right = kInvalidNode;
// 左子树:m_Entries[start..first-1]
// 这些 Entry 全部 intervalEnd < center,在 center 左侧结束
if (start < intervalTreeNode.first)
intervalTreeNode.left = Rebuild(start, intervalTreeNode.first - 1);
// 右子树:m_Entries[last+1..end]
// 这些 Entry 全部 intervalStart > center,在 center 右侧才开始
if (end > intervalTreeNode.last)
intervalTreeNode.right = Rebuild(intervalTreeNode.last + 1, end);
// 把填好的节点写回之前占的位
m_Nodes[index] = intervalTreeNode;
return index;
}
Query:查询
Rebuild 建好树之后,查询就是从根节点开始递归。每一层做两件事:扫当前节点管辖的 Entry 看谁包含查询时间,然后根据查询时间和 center 的关系决定往左还是往右递归。
还是用前面那 5 个 clip 的例子来跟一遍查询过程:
树结构(Rebuild 之后):
根节点(center=50)
管辖 {B:[20,60), C:[40,70)}
/ \
左子节点(叶) 右子节点(叶)
管辖 {A:[10,30)} 管辖 {D:[55,80), E:[60,90)}
查 t = 45:
- 根节点:扫 {B, C}。B: 20 ≤ 45 < 60 ✓ 命中。C: 40 ≤ 45 < 70 ✓ 命中
- 45 < center(50) → 递归左子节点,跳过右子节点(D、E 的 start 都 > 50 > 45,不可能包含 45)
- 左子节点:扫 {A}。A: 10 ≤ 45?是,但 45 < 30?否 ✗
- 结果 = {B, C},只扫了 3 个 Entry,跳过了 D 和 E
查 t = 65:
- 根节点:扫 {B, C}。B: 20 ≤ 65 < 60?否 ✗(65 已经超过 B 的终点了)。C: 40 ≤ 65 < 70 ✓ 命中
- 65 > center(50) → 递归右子节点,跳过左子节点(A 的 end = 30 < 50 < 65,不可能包含 65)
- 右子节点:扫 {D, E}。D: 55 ≤ 65 < 80 ✓ 命中。E: 60 ≤ 65 < 90 ✓ 命中
- 结果 = {C, D, E},跳过了 A
每层递归只走左或右一边,另一边整棵子树直接跳过。平均查询复杂度 O(log n + k),k 是结果数。
// IntervalTree.cs
private void Query(IntervalTreeNode intervalTreeNode, Int64 value, List<T> results)
{
// ---- 第一步:扫当前节点管辖的 Entry ----
// 这些 Entry 都是跨越了当前节点 center 的 clip
// 逐个检查 value 是否落在 [intervalStart, intervalEnd) 区间内
for (int i = intervalTreeNode.first; i <= intervalTreeNode.last; i++)
{
var entry = m_Entries[i];
// 注意是左闭右开:start <= value < end
// 右开是为了避免两个首尾相接的 clip 在交界点被同时查到
if (value >= entry.intervalStart && value < entry.intervalEnd)
{
results.Add(entry.item);
}
}
// ---- 第二步:决定递归方向 ----
// 叶节点没有子树,不用递归
if (intervalTreeNode.center == kCenterUnknown)
return;
// value < center → 往左递归
// 为什么可以跳过右子树?因为右子树里所有 Entry 的 intervalStart > center,
// 而 value < center < intervalStart,value 不可能落在它们的区间内
if (intervalTreeNode.left != kInvalidNode && value < intervalTreeNode.center)
Query(m_Nodes[intervalTreeNode.left], value, results);
// value > center → 往右递归
// 为什么可以跳过左子树?因为左子树里所有 Entry 的 intervalEnd < center,
// 而 value > center > intervalEnd,value 也不可能落在它们的区间内
if (intervalTreeNode.right != kInvalidNode && value > intervalTreeNode.center)
Query(m_Nodes[intervalTreeNode.right], value, results);
}
DiscreteTime:为什么不用 double 比
前面看到 IntervalTree 里不管是 Entry 的 intervalStart / intervalEnd 还是 Query 传进来的查询值,类型全是 Int64,不是 double。为什么 Timeline 不直接用 double 秒数做比较?
举个实际会碰到的场景:两个 clip 首尾紧挨着,clip A 的 end = 3.0s,clip B 的 start = 3.0s。理论上时间走到 3.0s 时应该只查到 B(因为区间是左闭右开,A 的 [start, end) 不包含 end 这个点)。但 double 在表示 3.0 的时候,经过一系列加减乘除之后实际存的可能是 2.9999999999999996 或 3.0000000000000004,差个 1e-15 量级。这时候 value >= entry.intervalStart 或 value < entry.intervalEnd 的比较就可能判反——本该查到的没查到,或者不该查到的被查进来了。
Timeline 的做法是引入 DiscreteTime 结构体,把 double 秒数转成 Int64 的 tick 值再比较。转换精度是 k_Tick = 1e-12(1 tick = 1 皮秒),转换时四舍五入到最近的 tick:ticks = (Int64)(time / 1e-12 + 0.5)。Int64 比较没有浮点漂移的问题,2999999999999 和 3000000000000 就是差 1,不会出现”差一点点但 >= 判成了 <”的情况。IntervalTree 里所有时间比较都走这套 Int64 tick,边界判断就稳了。
PrepareFrame → Evaluate:每帧核心循环
RuntimeClip 和 IntervalTree 都搞清楚了,现在可以看主循环了。TimelinePlayable 继承 PlayableBehaviour,重写了 PrepareFrame()。PlayableGraph 评估时按前序遍历触发每个节点的 PrepareFrame,TimelinePlayable 是根节点所以最先被调到,它里面就一行——转给 Evaluate():
// TimelinePlayable.cs
/// <summary>
/// 重写以处理时间轴实例上的时间同步。
/// </summary>
/// <param name="playable">拥有当前 PlayableBehaviour 的 Playable。</param>
/// <param name="info">包含当前帧上下文信息的 FrameData 结构。</param>
public override void PrepareFrame(Playable playable, FrameData info)
{
#if UNITY_EDITOR
// 编辑器下才会走这段:检查有没有 clip 区间变化
// 比如在 Timeline 编辑器里拖动了 clip 位置、改了 clip 长度等
// 有变化就标 dirty,让 IntervalTree 下次查询时先 Rebuild
if (m_Rebalancer != null)
m_Rebalancer.Rebalance();
#endif
// 运行时直接走 Evaluate
Evaluate(playable, info);
}
Evaluate() 就是 4.4 开头那张流程图的完整实现。一共 6 步,看之前先说一下涉及到的几个字段:
m_ActiveBit(int):0 或 1,每帧翻转一次,用来区分”上帧活跃”和”本帧活跃”m_ActiveClips(List<RuntimeElement>):存的是上一帧的活跃 clip 列表m_CurrentListOfActiveClips(List<RuntimeElement>):IntervalTree 查询结果,本帧活跃的 clip
看代码之前先把 ActiveBit 翻转这个设计单独说清楚,因为它贯穿了第 2~5 步。
每帧要处理两种情况:上帧有但本帧没有的 clip(要关掉)和上帧没有但本帧新来的 clip(要激活)。常规做法是用两个 HashSet 做差集,但 HashSet 每帧分配内存、有 GC 压力。源码只用了一个 int 字段 m_ActiveBit 就同时解决了这两个问题。举一个例子走一遍就明白了:
第 N 帧:m_ActiveBit = 0
IntervalTree 查出 {A, B, C}
打标:A.intervalBit=0, B.intervalBit=0, C.intervalBit=0
全部 EvaluateAt() 之后存入 m_ActiveClips = {A, B, C}
第 N+1 帧:m_ActiveBit 翻转成 1
IntervalTree 查出 {B, C, D}(A 离开了区间,D 新进入)
打标:B.intervalBit=1, C.intervalBit=1, D.intervalBit=1
注意 A 没被查到,它的 intervalBit 还是上帧的 0
▸ 第 4 步:遍历上帧的 m_ActiveClips = {A, B, C},找该关的:
A.intervalBit(0) != m_ActiveBit(1) → 上帧有本帧没有 → DisableAt() 关掉 ✓
B.intervalBit(1) == m_ActiveBit(1) → 两帧都有 → 跳过 ✓
C.intervalBit(1) == m_ActiveBit(1) → 两帧都有 → 跳过 ✓
▸ 第 5 步:遍历本帧的 {B, C, D},全部调 EvaluateAt():
B:已经是 Playing 状态 → enable=true 的 setter 检测到已在播,跳过 Play()
→ 只更新权重和时间(正常刷新)
C:同上,正常刷新
D:上帧被 DisableAt 过,是 Paused 状态 → enable=true 触发 Play() + SetTime(clipIn)
→ 新 clip 被激活 ✓
全部放入 m_ActiveClips = {B, C, D},下一帧变成"上帧列表"
所以 ActiveBit 只负责找出”该关的”,”该开的”不需要额外判断——第 5 步对所有本帧活跃 clip 统一调 EvaluateAt(),enable setter 内部会自动判断:已经在播的就只刷新权重和时间,没在播的就 Play 起来。一个 int 字段 + 一次翻转,不用分配任何临时集合。
// TimelinePlayable.cs
private void Evaluate(Playable playable, FrameData frameData)
{
if (m_IntervalTree == null)
return;
// ---- 第 1 步:取当前时间 ----
double localTime = playable.GetTime();
// ---- 第 2 步:翻转标记位 ----
// m_ActiveBit 在 0 和 1 之间切换
// 比如上一帧 m_ActiveBit = 0,上帧查出的 clip 身上的 intervalBit 都被设成了 0
// 这帧翻转成 1,等下查出的 clip 会被打上 1
// 这样只要比较 clip.intervalBit 是不是等于 m_ActiveBit,
// 就能知道它是"本帧活跃"还是"上帧遗留"
m_ActiveBit = m_ActiveBit == 0 ? 1 : 0;
// ---- 第 3 步:IntervalTree 查询 ----
// 把当前时间(double 秒)转成 Int64 tick,丢进 IntervalTree 查
// 查询结果填进 m_CurrentListOfActiveClips
m_CurrentListOfActiveClips.Clear();
m_IntervalTree.IntersectsWith(DiscreteTime.GetNearestTick(localTime), m_CurrentListOfActiveClips);
// 给本帧查出来的每个 clip 打上新的 bit 标记
foreach (var c in m_CurrentListOfActiveClips)
{
c.intervalBit = m_ActiveBit;
}
// ---- 第 4 步:关掉不再活跃的 clip ----
// m_ActiveClips 里存的是上帧的活跃列表
// 遍历它,如果某个 clip 的 intervalBit 还是旧值(!= m_ActiveBit),
// 说明本帧 IntervalTree 没查到它 → 它离开了激活区间 → 要关掉
var timelineEnd = (double)new DiscreteTime(playable.GetDuration());
foreach (var c in m_ActiveClips)
{
if (c.intervalBit != m_ActiveBit)
c.DisableAt(localTime, timelineEnd, frameData);
}
// ---- 第 5 步:激活本帧活跃的 clip ----
// 清空 m_ActiveClips,把本帧查出来的 clip 逐个 EvaluateAt 之后放进去
// 下一帧这个列表就变成了"上帧活跃列表"
m_ActiveClips.Clear();
for (var a = 0; a < m_CurrentListOfActiveClips.Count; a++)
{
// EvaluateAt 里面做三件事:Play + 写权重 + 设 localTime
// 后面单独展开
m_CurrentListOfActiveClips[a].EvaluateAt(localTime, frameData);
m_ActiveClips.Add(m_CurrentListOfActiveClips[a]);
}
// ---- 第 6 步:后处理回调 ----
// 4.3 建图时 AnimationTrack 注册了 AnimationOutputWeightProcessor
// 它就在这里被调到,负责动画权重归一化(后面单独讲)
int count = m_EvaluateCallbacks.Count;
for (int i = 0; i < count; i++)
{
m_EvaluateCallbacks[i].Evaluate();
}
}
RuntimeClip.EvaluateAt:写权重、设时间
Evaluate 第 5 步里对每个活跃 clip 调的 EvaluateAt(),就是”写权重”真正发生的地方。这个方法做四件事:确保在播 → 算权重 → 写权重到 Mixer → 设 clip 本地时间。
先不看代码,把权重计算的规则理清楚。一个 RuntimeClip 的完整区间分三段:
前外推区间 正常区间 后外推区间
|← Pre →| ┌──────────────┐ |← Post →|
↑ start ↑ end
不同区间的权重计算方式不一样:
- 前外推区间(
localTime < clip.start):权重 =EvaluateMixIn(clip.start),是个固定值。因为外推区间里 clip 内容是”虚拟延伸”出来的,权重不应该随时间变,直接取 clip 起始点处的 MixIn 值就行 - 后外推区间(
localTime > clip.end):权重 =EvaluateMixOut(clip.end),也是固定值,同理取结束点处的 MixOut 值 - 正常区间(
clip.start ≤ localTime ≤ clip.end):权重 =EvaluateMixIn(localTime) × EvaluateMixOut(localTime),两个曲线值相乘
正常区间里 MixIn 和 MixOut 相乘是什么意思?用两个交叉的 clip 举个例子:
时间轴:
0 1 2 3 4 5 6
| | | | | | |
clip A: [1s ════════════════════════════ 4s]
clip B: [2.5s ════════════════════════════ 5.5s]
↑ ↑
2.5s 4s
← 交叉区域 1.5s →
clip A 在 [2.5s, 4s] 这段是 MixOut 区间(和 B 重叠的部分):
A 的 MixOut 从 1.0 渐降到 0
clip B 在 [2.5s, 4s] 这段是 MixIn 区间:
B 的 MixIn 从 0 渐升到 1.0
比如 t = 3.25s(交叉区域的中点):
A 的权重 = MixIn(3.25) × MixOut(3.25) ≈ 1.0 × 0.5 = 0.5
B 的权重 = MixIn(3.25) × MixOut(3.25) ≈ 0.5 × 1.0 = 0.5
两个 clip 各占一半,平滑过渡
EvaluateMixIn() / EvaluateMixOut() 的具体曲线形状由 TimelineClip 上的 m_MixInCurve / m_MixOutCurve 决定,编辑器里可以选 EaseIn/EaseOut 模板或者自定义曲线,返回值都在 [0, 1] 范围内。
下面这张图比较直观,蓝色曲线是 MixIn(从 0 升到 1),红色曲线是 MixOut(从 1 降到 0),中间权重为 1 的平坦区域就是没有和其他 clip 交叉的正常段:

理解了权重计算规则之后,看源码就很清晰了:
// RuntimeClip.cs
/// <summary>
/// 在指定时间评估方法
/// 在给定的本地时间点评估片段,更新权重和时间
/// </summary>
/// <param name="localTime">本地时间</param>
/// <param name="frameData">帧数据</param>
public override void EvaluateAt(double localTime, FrameData frameData)
{
// ---- 第 1 步:确保节点在 Playing 状态 ----
// 新进入区间的 clip 会被 Play 起来
// 已经在播的 clip,enable setter 检测到已是 Playing 会跳过
enable = true;
// 时间轴 loop(WrapMode=Loop)时需要重置
// SetTime 调两次是为了让 previousTime 和 time 都重置到 clipIn,
// 避免 deltaTime 算出一个巨大的跳变值
if (frameData.timeLooped)
{
SetTime(clip.clipIn);
SetTime(clip.clipIn);
}
// ---- 第 2 步:按区间计算权重 ----
float weight = 1.0f;
if (clip.IsPreExtrapolatedTime(localTime))
// 前外推区间:取 clip 起始点处的 MixIn 值作为固定权重
weight = clip.EvaluateMixIn((float)clip.start);
else if (clip.IsPostExtrapolatedTime(localTime))
// 后外推区间:取 clip 结束点处的 MixOut 值作为固定权重
weight = clip.EvaluateMixOut((float)clip.end);
else
// 正常区间:MixIn × MixOut
// 没有和其他 clip 交叉时 MixIn=1, MixOut=1,乘出来还是 1
// 交叉时一个渐降一个渐升,乘出来就是交叉淡化的权重
weight = clip.EvaluateMixIn(localTime) * clip.EvaluateMixOut(localTime);
// ---- 第 3 步:写权重到 Mixer ----
// 就是 mixer.SetInputWeight(playable, weight)
// 4.3 建图时初始权重设的 0,运行时就是这里把正确的权重写上去的
if (mixer.IsValid())
mixer.SetInputWeight(playable, weight);
// ---- 第 4 步:转 clip 本地时间并设给叶子节点 ----
// ToLocalTime 是 4.2 讲过的方法:
// 时间轴全局时间 → 减去 clip.start → 乘以 timeScale → 加上 clipIn
// 处理外推区间时还会走 Loop/Hold/PingPong 等逻辑
double clipTime = clip.ToLocalTime(localTime);
if (clipTime >= -DiscreteTime.tickValue / 2)
{
SetTime(clipTime); // 叶子节点(AnimationClipPlayable 等)按这个时间播放
}
SetDuration(clip.extrapolatedDuration);
}
RuntimeClip.DisableAt:离开区间
Evaluate 第 4 步里,ActiveBit 对比发现”上帧有但本帧没有”的 clip 会被调 DisableAt()。这个方法做的事情比 EvaluateAt 简单得多,但有一个容易忽略的细节:关掉 clip 之前要先把时间设到正确的位置。
为什么要先设时间?举个例子:一段攻击动画 clip 在 [2s, 5s](没开外推),时间轴跑到 5.016s 时(刚过了 clip 结尾一帧)IntervalTree 查不到这个 clip 了,DisableAt 被调用。源码先算 Math.Min(5.016, 5.0) = 5.0(取当前时间和区间终点的较小值,保证不越界),然后 ToLocalTime(5.0) 转成 clip 本地时间 SetTime 进去,让节点停在 clip 最后一帧对应的姿势。如果不设这一步直接 Pause,叶子节点还停在上一帧 EvaluateAt 设的位置(比如 4.98s 对应的姿势),虽然权重马上被清零不影响混合结果,但状态不干净。
// RuntimeClip.cs
/// <summary>
/// 在指定时间禁用方法
/// 在给定的本地时间点禁用片段并清理状态
/// </summary>
/// <param name="localTime">本地时间</param>
/// <param name="rootDuration">根持续时间</param>
/// <param name="frameData">帧数据</param>
public override void DisableAt(double localTime, double rootDuration, FrameData frameData)
{
// localTime 可能已经超过了 clip 的区间终点
// (因为是"这帧才发现上帧的 clip 不在了",时间已经走过去了)
// 取 min 保证不超过区间边界,否则 ToLocalTime 算出来的值不对
var time = Math.Min(localTime, (double)DiscreteTime.FromTicks(intervalEnd));
// 时间轴 loop 的情况(WrapMode=Loop):
// localTime 可能已经回绕到很小的值(比如 0.1s),
// 但 rootDuration 是时间轴总长度,clamp 一下避免越界
if (frameData.timeLooped)
time = Math.Min(time, rootDuration);
// 把时间转成 clip 本地时间,设给叶子节点
// 这样 Pause 的时候节点停在的是 clip 最后一帧的正确位置
var clipTime = clip.ToLocalTime(time);
if (clipTime > -DiscreteTime.tickValue / 2)
{
SetTime(clipTime);
}
// 最后:Pause 节点 + Mixer inputWeight 清零
// 走的就是前面讲过的 enable setter
enable = false;
}
整个流程就两步:先 SetTime 到 clip 离开时的正确位置,再 enable = false 关掉节点。和 EvaluateAt 刚好对称——一个负责”进来时 Play + 写权重 + 设时间”,一个负责”离开时设最后时间 + Pause + 清权重”。
AnimationOutputWeightProcessor:动画权重后处理
Evaluate() 最后跑 m_EvaluateCallbacks,动画轨道注册了 AnimationOutputWeightProcessor。为什么动画需要特殊处理?
EvaluateAt 按 MixIn/MixOut 写完权重后,Mixer 的所有 input 权重加起来可能不到 1。比如一个 clip 正在 MixIn,权重才 0.3——剩下的 0.7 去哪了?AnimationMixerPlayable 会把缺失的部分混入默认姿势(通常是 T-Pose),视觉上角色就会”抽搐”或出现奇怪的姿势。
AnimationOutputWeightProcessor 就是来解决这个问题的。它在构造时把 AnimationPlayableOutput 的初始权重设为 0(4.3 里 EvaluateWeightsForAnimationPlayableOutput 注册的就是这个),然后每帧跑 Evaluate() 做归一化。
核心:NormalizeMixer
这个函数做两件事,容易搞混所以拆开说:
- 放大 Mixer 内部权重:如果 input 权重之和不到 1(比如 clip0=0.26 + clip1=0.14 = 0.40),就按比例除以总权重,让它们加起来恰好等于 1(变成 0.65 + 0.35)。这样 Mixer 内部做混合时,100% 都是 clip 动画,没有默认姿势(T-Pose)的份额。总权重 >= 1 时不缩放,已经够了。
- 返回放大前的原始总权重(
Clamp01)。这个值代表”这个 Mixer 的动画覆盖率”——比如 0.40 意味着”我这个分支只有 40% 的动画是活的”。返回值会被设成 Mixer 在父节点上的 inputWeight,让上一级知道该给这个分支多少影响力。
先放大保证 Mixer 内部干净,再把原始值交给上一级——两件事不要搞混。
// WeightUtility.cs
public static float NormalizeMixer(Playable mixer)
{
if (!mixer.IsValid())
return 0;
// 求 Mixer 所有 input 权重之和
int count = mixer.GetInputCount();
float weight = 0.0f;
for (int c = 0; c < count; c++)
{
weight += mixer.GetInputWeight(c);
}
// 总权重在 (0, 1) 之间:按比例放大到和为 1
// 避免缺失部分混入默认姿势
if (weight > Mathf.Epsilon && weight < 1)
{
for (int c = 0; c < count; c++)
{
mixer.SetInputWeight(c, mixer.GetInputWeight(c) / weight);
}
}
// 总权重 >= 1:不缩放(已经够了,不需要放大)
// 返回 Clamp01(totalWeight) 作为这个 Mixer 在父节点上的权重
return Mathf.Clamp01(weight);
}
Evaluate 流程
// AnimationOutputWeightProcessor.cs
public void Evaluate()
{
float weight = 1;
// 先把 Output weight 设成 1;编辑器模式下不会被后面覆盖,保持 1 混合到默认值
m_Output.SetWeight(1);
// m_Mixers 是后序遍历收集的,子 Mixer 在前、父 Mixer 在后
// 所以先归一化最内层,再归一化外层,覆盖率信息从内到外传递
for (int i = 0; i < m_Mixers.Count; i++)
{
var mixInfo = m_Mixers[i];
// 归一化 Mixer 内部 input 权重(放大到和为 1)
// 返回值 = 放大前的原始总权重,即这个分支的"动画覆盖率"
weight = WeightUtility.NormalizeMixer(mixInfo.mixer);
// 把覆盖率设为 Mixer 在父节点上的 inputWeight
mixInfo.parentMixer.SetInputWeight(mixInfo.port, weight);
}
// 循环结束后 weight 是最外层 Mixer 的覆盖率
// 播放模式下设给 Output,让 Animator 知道动画整体覆盖了多少
if (Application.isPlaying)
m_Output.SetWeight(weight);
}
用具体数值走一遍。假设一条带 Override 子轨的 AnimationTrack,clip 都处在 MixIn/MixOut 过渡区间,权重没拉满:
情况 1:总权重 < 1(clip 都在过渡期)
AnimationPlayableOutput
└─ TimelinePlayable (root)
└─ AnimationLayerMixerPlayable (LayerMixer)
├─ input 0: AnimationMixerPlayable (基础层 Mixer)
│ ├─ clip0: weight 0.26
│ └─ clip1: weight 0.14
└─ input 1: AnimationMixerPlayable (Override 层 Mixer)
├─ clip2: weight 0.19
└─ clip3: weight 0.13
这些权重是 EvaluateAt 根据 MixIn/MixOut 算出来的原始值。clip 都在过渡区间,所以没有任何一个是满权重。
① 归一化基础层 Mixer(input 0):
- 总权重 = 0.26 + 0.14 = 0.40 < 1 → 触发归一化,把 input 权重放大到和为 1
- clip0:0.26 / 0.40 = 0.65
- clip1:0.14 / 0.40 = 0.35
- 现在 Mixer 内部是 0.65 + 0.35 = 1.0,混合结果 100% 是 clip 动画,没有默认姿势混进来
- 返回放大前的原始总权重 0.40 → 设给 LayerMixer.input[0].weight(告诉上一级”基础层这个分支只有 40% 的动画是活的”)
② 归一化 Override 层 Mixer(input 1):
- 总权重 = 0.19 + 0.13 = 0.32 < 1 → 归一化
- clip2:0.19 / 0.32 = 0.59
- clip3:0.13 / 0.32 = 0.41
- 返回 0.32 → 设给 LayerMixer.input[1].weight
③ 归一化 LayerMixer:
- input 权重现在是第①②步返回的 0.40 和 0.32,总和 = 0.72 < 1 → 归一化
- input 0:0.40 / 0.72 = 0.56
- input 1:0.32 / 0.72 = 0.44
- 返回 0.72
LayerMixer 是 m_Mixers 列表的最后一个(后序遍历,最外层最后处理),所以这个 0.72 就是 Evaluate() 结尾 m_Output.SetWeight(weight) 写进去的值。
Playable Graph Visualizer 里看到的就是这个效果——Mixer 内部 clip 权重被放大(0.26→0.65、0.14→0.35 等),Output weight = 0.72:

Output weight = 0.72,到底会不会混入默认姿势?
Mixer 内部不会,但 Output 级别会。 分两层看:
- Mixer 内部:每一层 Mixer 的 input 权重都被归一化到 1.0,Mixer 做混合运算时只有 clip 动画参与,不存在”空出来的份额被默认姿势填充”的问题。这是 NormalizeMixer 的核心作用。
- Output 级别:
AnimationPlayableOutput.weight = 0.72意味着 Animator 拿到的最终结果是 72% Playable Graph 动画 + 28% 默认姿势(Bind Pose 或 Animator Controller 的输出)。
这不是 bug。所有 clip 都在过渡期、权重没拉满,说明动画确实没有 100% 覆盖。Output 如实反映了这个覆盖率。如果强行把 Output 设成 1.0,等于对外谎报”动画已经满了”——反而不对。
实际使用中问题大吗?通常不大。clip 之间做 crossfade 时一个 MixOut 一个 MixIn,权重总和接近 1,Output weight 也接近 1,几乎看不到默认姿势。只有”第一个 clip 还在淡入”或”最后一个 clip 正在淡出”这种边界情况,才会看到默认姿势混进来。不想看到的话,给首尾 clip 开 Pre/Post Extrapolation Hold 就行——提前把权重撑到 1。
情况 2:总权重 >= 1(clip 权重已经够满)
AnimationPlayableOutput
└─ TimelinePlayable (root)
└─ AnimationLayerMixerPlayable (LayerMixer)
├─ input 0: AnimationMixerPlayable (基础层 Mixer)
│ ├─ clip0: weight 0.6
│ └─ clip1: weight 0.9
└─ input 1: AnimationMixerPlayable (Override 层 Mixer)
├─ clip2: weight 0.2
└─ clip3: weight 0.3
基础层两个 clip 权重比较大(crossfade 重叠区间,两个都接近满权重),Override 层的 clip 还在过渡期。
① 归一化基础层 Mixer(input 0):
- 总权重 = 0.6 + 0.9 = 1.5 >= 1 → 不缩放,权重已经够了
- clip0 保持 0.6,clip1 保持 0.9,不做任何调整
- 返回 Clamp01(1.5) = 1.0 → 设给 LayerMixer.input[0].weight(告诉上一级”基础层动画已经 100% 覆盖了”)
② 归一化 Override 层 Mixer(input 1):
- 总权重 = 0.2 + 0.3 = 0.5 < 1 → 归一化
- clip2:0.2 / 0.5 = 0.4
- clip3:0.3 / 0.5 = 0.6
- 返回 0.5 → 设给 LayerMixer.input[1].weight
③ 归一化 LayerMixer:
- input 权重是第①②步返回的 1.0 和 0.5,总和 = 1.5 >= 1 → 不缩放
- input 0 保持 1.0,input 1 保持 0.5
- 返回 Clamp01(1.5) = 1.0 → 设给 AnimationPlayableOutput.weight
这次 Output weight = 1.0,不会混入默认姿势。因为基础层 clip 权重够满(总和 >= 1),整条链路的覆盖率一路传上来都是 1.0。
对应 Visualizer 截图——基础层的 clip 权重保持 0.6、0.9 不变(总权重 >= 1 不缩放),Override 层内部做了归一化(0.2→0.4、0.3→0.6),Output weight = 1.0:

对比两种情况:情况 1 所有 clip 都在过渡期,覆盖率不满,Output < 1 会混入一点默认姿势;情况 2 至少有一层 clip 权重够满,覆盖率传到顶层就是 1.0,Output = 1 完全不混入默认姿势。实际项目中大部分时间都是情况 2——只要 clip 之间 crossfade 衔接得当,基础层权重总和很容易 >= 1。
小结
graph TD
subgraph callbackEntry["每帧回调"]
A["TimelinePlayable.PrepareFrame()"] --> B["Evaluate()"]
end
subgraph coreSteps["核心步骤"]
B --> C["取时间 → 转 DiscreteTime tick"]
C --> D["翻转 ActiveBit"]
D --> E["IntervalTree.IntersectsWith 查活跃 Clip"]
E --> F["对比上帧:
不活跃 → DisableAt(Pause + 权重清零)
活跃 → EvaluateAt(Play + 算权重 + 设时间)"]
F --> G["执行 EvaluateCallbacks:
AnimationOutputWeightProcessor 归一化权重
其他自定义回调"]
end
subgraph infrastructure["底层保障"]
H["IntervalTree 建树 O(n log n)"] --> E
I["DiscreteTime 避免浮点精度问题"] --> C
J["ActiveBit 翻转避免 GC"] --> D
end
| 每帧做了什么 | 源码方法 | 效果 |
|---|---|---|
| 查活跃 clip | IntervalTree.IntersectsWith() |
把 double 时间转成 Int64 tick,在区间树里找出本帧处于 [start, end) 内的 RuntimeClip |
| 关掉离开的 | RuntimeClip.DisableAt() |
SetTime 到最后有效时间 → enable = false(Pause + Mixer inputWeight 清零) |
| 激活并写权重 | RuntimeClip.EvaluateAt() |
enable = true(Play)→ 按前外推/正常/后外推三种情况算 MixIn×MixOut 权重 → mixer.SetInputWeight() → ToLocalTime() 转本地时间 → SetTime() |
| 动画后处理 | AnimationOutputWeightProcessor.Evaluate() |
后序遍历所有 AnimationMixer,总权重 < 1 时归一化到 1(Mixer 内部不混入默认姿势),返回原始总权重作为覆盖率设给父节点和 Output |
回头对照 4.3 Visualizer 那张截图就能串起来了——建图阶段所有叶子节点都是 Paused、Mixer 上每个 input 的 weight 都是 0,图虽然连好了但什么都没在播。每帧就是靠这套流程把该播的 clip Play 起来、算好权重写到 Mixer 上,不该播的 Pause 掉清零,最终 Mixer 混合出正确的动画 / 音频结果输出给 Animator / AudioSource。
4.5 总结
这篇从 .playable 文件的序列化格式开始,先看了 TimelineAsset、TrackAsset、TimelineClip 这些资产类怎么组织的、PlayableBinding 怎么把轨道和场景对象关联起来,然后跟了 Director.Play() 之后的完整建图流程——每条轨道怎么建 Mixer、clip 怎么被包成 RuntimeClip 塞进 IntervalTree、Override 子轨怎么走 LayerMixer 做分层混合、Output 怎么创建。最后看了每帧评估的核心循环:IntervalTree 区间查询找出活跃 clip,ActiveBit 翻转判断哪些该关哪些该开,EvaluateAt 按 MixIn/MixOut 算权重写到 Mixer,AnimationOutputWeightProcessor 归一化防止混入默认姿势。
看代码的过程中能感觉到 Timeline 在每帧评估的热路径上花了不少心思——IntervalTree 建树直接在 m_Entries 上原地 partition 不额外分配内存,clip 进出判定靠 ActiveBit 一个 int 翻转就搞定了不需要 HashSet,时间比较全部转成 Int64 tick 绕开 double 的浮点精度漂移。这些细节不翻源码基本注意不到,但恰恰是 clip 数量上去之后每帧评估不出性能问题的关键。后面写自定义轨道或者排查运行时问题的时候,知道 CompileClips、EvaluateAt、NormalizeMixer 这几个方法在干什么,定位起来会快很多。
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com