4.Timeline源码浅析

4.Timeline源码浅析


4.1 为什么要学习 Timeline 源码

学习动机

Timeline 做技能编辑和过场动画都很常用,但要做深度定制(写自定义轨道、运行时动态控制、性能调优),光会在窗口里拖 clip 不够,得看源码才知道里面怎么回事。

Timeline 底层是 Playable 系统,关于 Playable 本身可以看前面的《10.Playable源码浅析》。

分层与本质

Timeline 源码的重点就三个:建图、选片段、写权重

代码分两层。C# 这边处理轨道、片段、绑定这些上层逻辑,真正建图连节点、每帧评估写权重,走的是 C++ 的 Playable 系统,C# 通过 PlayableAPI 调过去:

Timeline 侧核心类:PlayableAssetPlayableBindingTrackAssetPlayableDirector,决定播什么、绑给谁。Playable 侧:PlayablePlayableOutputPlayableGraph,负责图的构建和评估:

Timeline 底层干的事就三步:把片段按时间排好,编译成 PlayableGraph,然后每帧用 IntervalTree 查当前时间该激活哪些 clip、写权重。IntervalTree 本质就是把”每帧找活跃 clip”从遍历数组变成区间查询,clip 多了之后查询性能差距就出来了。TimelinePlayable.PrepareFrame() 每帧拿查询结果决定谁播、权重多少。技能编辑也好,过场演出也好,走的都是 资产 → 建图 → 每帧评估 这条路。

Timeline 术语

Timeline 相关的词比较多,而且同一个词在不同上下文意思不一样,先捋清楚。

Timeline 这个词本身就有三个意思:

  • Timeline 窗口 — 编排轨道和片段的编辑器面板。
  • Timeline 文件 — 保存出来的 .playable 文件。
  • TimelineAsset.playable 文件对应的类,继承 PlayableAsset,也实现了 ITimelineClipAsset(所以一条 Timeline 可以嵌套进另一条当 clip 用)。它持有所有轨道,建图时创建运行时的根节点。

轨道和片段相关的几个类:

  • PlayableAsset — 引擎层的基类,定义了 CreatePlayable()outputsTimelineAssetTrackAsset、各种 clip 资产都继承它。
  • ITimelineClipAsset — 接口,就一个 clipCaps 属性,声明这个 clip 支不支持混合、循环之类的能力。
  • TrackAsset — 轨道。继承 PlayableAsset,下面挂着一堆 TimelineClip。运行时建 Mixer,把 clip 的 Playable 塞进区间树。
  • TimelineClip — 窗口里那一截一截的长条。存的是 start、duration、混合曲线,以及对一个 PlayableAsset 的引用(asset 字段)。注意它本身不创建 Playable,只是数据描述。通过 asset as ITimelineClipAssetclipCaps
  • PlayableClipAsset(通称)— TimelineClip 背后真正的资产,比如 AnimationPlayableAssetControlPlayableAsset。建图时被调 CreatePlayable() 创建实际节点。

绑定和驱动:

  • PlayableBindingPlayableAsset.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 选片段写权重()
  }

源码学习路线

本篇博客按下面的顺序来拆解,基本上是从”看得见的”到”跑起来的”:

  1. 静态结构:先搞清楚 .playable 文件里存了什么。TimelineAssetTrackAssetTimelineClip → clip 资产,这棵树是一切的起点。把静态资产的组织方式弄明白了,后面看建图代码才知道它在遍历什么。
  2. 运行时建图PlayableDirector.Play() 之后发生了什么?从 TimelineAsset.CreatePlayable()TimelinePlayable.Compile(),再到每条轨道的 CreatePlayableGraph()CompileClips(),整条调用链怎么把静态资产编译成 PlayableGraph 里的节点树。
  3. 每帧评估:图建好之后每帧怎么跑。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。用文本编辑器打开能直接看到结构:根是 TimelineAssetm_Namem_Tracks 等),m_Tracks 里按 fileID 引用各 root track;每个 Track 子资产有 m_Parent 指回 TimelineAsset、m_Clips 里存着 TimelineClip,clip 里的 m_Startm_Durationm_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_TracksList<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 子轨。

所以后面建图时会看到三种情况:

  1. 有子轨且支持 Layer(AnimationTrack)—— 建 LayerMixer,每层各自编译
  2. 就自己一条轨(大多数情况)—— 直接编译
  3. 有子轨但不支持 Layer(理论上不常见)—— 把所有 clip 合并后编译

Attribute 声明能力

轨道通过 Attribute 告诉 Timeline 自己的能力:

  • TrackClipType —— 这条轨能放哪种 clip。参数是实现了 ITimelineClipAsset 的 PlayableAsset 类型。
  • TrackBindingType —— 这条轨的 Output 要绑什么类型的目标,对应 PlayableBindingoutputTargetType

比如 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 枚举,有 LoopingExtrapolationClipInSpeedMultiplierBlending 等。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 绑定表拿它当 key
  • outputTargetType — 从 TrackBindingType Attribute 读出来的类型,比如 AnimationTrack 对应 Animator,AudioTrack 对应 AudioSource
  • streamName — 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_MarkersMarkerList),任何轨道都能放 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。

MarkerTrackoutputs 有点特殊:如果是 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() 对每条轨道做四步:

  1. 确定父节点 — output track 列表里可能有 Override 子轨,子轨要连到父轨的 LayerMixer 上而不是根节点上,所以先判断 track.parent 是不是 TrackAsset:是的话递归编译父轨(缓存里有就直接拿),不是的话说明是普通顶层轨道,父节点就是根 TimelinePlayable
  2. track.CreatePlayableGraph() 建子树 — 走 CreateMixerPlayableGraphCompileClips 链路,建出 Mixer + 各 clip 叶子节点,同时把 RuntimeClip 塞进 IntervalTree
  3. graph.Connect() 接入图 — 父节点动态扩一个 input 口,把子树接上去,初始权重 1.0
  4. CreateTrackOutput() 建 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 塞进 IntervalTree
  • CreateNotificationsPlayable() — 收集轨道上实现了 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 结构体持有 PlayableHandleIsValid() 调的是 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。轨道建图走的是 CreatePlayableGraphCompileClips 这条链路,不走 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 做区间查询用的接口,只有两个属性——intervalStartintervalEnd,类型是 Int64(tick 值,不是 double 秒数,后面 DiscreteTime 那节会解释为什么)。

RuntimeElement 实现了 IInterval,同时定义了每帧评估需要的方法和字段:

  • intervalBit:一个 int 标记位,用来区分”上帧活跃”和”本帧活跃”,后面讲 Evaluate 主循环时会详细说它的妙用
  • enable:只有 setter,设 true 就 Play 叶子节点,设 false 就 Pause + 权重清零
  • EvaluateAt() / DisableAt():分别在 clip 进入和离开激活区间时被调用

RuntimeClipBase 加了 startduration 两个 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-ExtrapolationPost-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 的 startduration——它取的不是 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),但前外推**同时影响了 extrapolatedStartextrapolatedDuration**——起点前移了,为什么 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 = trueDisableAt() 最后一件事就是 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.MaxValuekCenterUnknown),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:

  1. 根节点:扫 {B, C}。B: 20 ≤ 45 < 60 ✓ 命中。C: 40 ≤ 45 < 70 ✓ 命中
  2. 45 < center(50) → 递归左子节点,跳过右子节点(D、E 的 start 都 > 50 > 45,不可能包含 45)
  3. 左子节点:扫 {A}。A: 10 ≤ 45?是,但 45 < 30?否 ✗
  4. 结果 = {B, C},只扫了 3 个 Entry,跳过了 D 和 E

查 t = 65:

  1. 根节点:扫 {B, C}。B: 20 ≤ 65 < 60?否 ✗(65 已经超过 B 的终点了)。C: 40 ≤ 65 < 70 ✓ 命中
  2. 65 > center(50) → 递归右子节点,跳过左子节点(A 的 end = 30 < 50 < 65,不可能包含 65)
  3. 右子节点:扫 {D, E}。D: 55 ≤ 65 < 80 ✓ 命中。E: 60 ≤ 65 < 90 ✓ 命中
  4. 结果 = {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.intervalStartvalue < 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_ActiveClipsList<RuntimeElement>):存的是上一帧的活跃 clip 列表
  • m_CurrentListOfActiveClipsList<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

这个函数做两件事,容易搞混所以拆开说:

  1. 放大 Mixer 内部权重:如果 input 权重之和不到 1(比如 clip0=0.26 + clip1=0.14 = 0.40),就按比例除以总权重,让它们加起来恰好等于 1(变成 0.65 + 0.35)。这样 Mixer 内部做混合时,100% 都是 clip 动画,没有默认姿势(T-Pose)的份额。总权重 >= 1 时不缩放,已经够了。
  2. 返回放大前的原始总权重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

×

喜欢就点赞,疼爱就打赏