3.PlayableDirector
3.1 知识点
PlayableDirector 概述
回顾比喻:
- TimelineAsset = 剧本(资产):只描述“什么时候做什么”
- PlayableDirector = 导演(运行时入口):拿着剧本,把“要演的对象”绑好,再把整台戏跑起来
- PlayableGraph = 舞台(运行时图)
- Playable = 戏(节点在跑)
PlayableDirector 主要做三件事:
- 场景绑定(Binding):把 Track/ExposedReference 需要的对象,绑定到场景实例上
- 构建 PlayableGraph:把 TimelineAsset 变成运行时图
- 控制播放:Play / Pause / Stop / 跳时间 / Evaluate
PlayableDirector 参数

Playable(Timeline Asset)
- 填的是
.playable资源(TimelineAsset)。 - 例:同一份
.playable资源挂两个 Director,Bindings 绑不同角色,就能复用一套Timeline资源(过场/技能模板)。
- 填的是
Update Method
- 决定“时间用哪套时钟走”。
- Game Time:跟着
Time.timeScale走(游戏暂停/减速,它也跟着暂停/减速)。 - Unscaled Game Time:不吃
timeScale(游戏暂停了它还在走)。UI 动画、暂停菜单里的演出常用。 - DSP Clock:跟音频 DSP 时钟走,主要是为了更稳的音频同步(卡点 BGM节拍或者音游常用)。
- Manual:不自动走时间;脚本自己改
director.time,再手动Evaluate()刷新(做预览器/编辑器拖时间条常用)。
Play On Awake
- 是否对象激活时自动
Play()。
- 是否对象激活时自动
Wrap Mode
- 决定“播到末尾怎么收场”。
- Hold:停在最后一帧(角色保持最后姿势/镜头停住)。
- Loop:从头循环(循环演出/循环 BGM 常见)。
- None:播完即结束。选它时,播完后被 Timeline 改过的属性会恢复成播前状态,表现就是播完立刻跳回原样,一次性过场且要恢复现场时用。
Initial Time
- 指定首次播放的起始时间(单位秒)。默认
0就是从开头播。 - 例:如果想从中间开始预览某段技能,把它改成
1.5,再点 Play。
- 指定首次播放的起始时间(单位秒)。默认
Bindings
- 每条 Track 在这里对应一个“演员槽位”(例如 Animation Track 绑
Animator,Audio Track 绑AudioSource)。 - 这里填的是场景对象;运行时也能用
SetGenericBinding()动态改(同一份 Timeline,换主角/换模型都靠它)。
- 每条 Track 在这里对应一个“演员槽位”(例如 Animation Track 绑
PlayableDirector代码控制相关API
常用字段
- **
playableAsset**:用于实例化 Graph 的PlayableAsset(TimelineAsset 也属于它)。运行时替换它后,通常需要重建图来刷新绑定与结构。 - **
playableGraph**:Director 创建的PlayableGraph。注意:Stop()会销毁图,图无效时不要指望它还能 Evaluate/Seek。 - **
time**:当前时间(秒)。播放时会按timeUpdateMode增长;也可以手动改,再配合Evaluate()立刻刷新到该时刻。 - **
duration**:当前连接的 Playable 的总时长(秒)。 - **
timeUpdateMode**:控制播放时“时间怎么增长”(GameTime / UnscaledGameTime / DSPClock / Manual)。 - **
extrapolationMode**:当time超出duration后如何处理(Hold/Loop/None)。 - **
initialTime**:第一次播放开始时的起始时间。 - **
state**:播放状态(PlayState)。Stop 更像“动作 + 事件”,建议用stopped回调/Graph 是否有效来判断停止。
常用方法
- **
Play()**:开始播放(必要时构建 Graph)。 - **
Pause()/Resume()**:暂停/继续(Pause 会停止 Graph 的自动更新时间)。 - **
Stop()**:停止播放并销毁图(下次播放会重建)。 - **
Evaluate()**:在当前time立刻评估一次(常用于跳时间、Manual 刷新)。 DeferredEvaluate():标记“下次更新时评估一次”,但只在 Edit Mode 生效,Play Mode 不工作。- **
RebuildGraph()**:重建图,并尽量保持当前播放状态(播放中会停播→销毁→重建→继续)。 - 绑定相关:
SetGenericBinding/GetGenericBinding、SetReferenceValue/GetReferenceValue(Timeline 实战经常用)。
常用回调
- **
played**:开始播放时触发。 - **
paused**:暂停时触发。 - **
stopped**:停止或播放结束进入停止时触发(收尾逻辑常放这里)。
常用API代码实现
using System;
using UnityEngine;
using UnityEngine.Playables;
/// <summary>
/// PlayableDirector 的示例控制器:
/// - 演示 timeUpdateMode / extrapolationMode 的常用配置
/// - 支持 Manual 模式下自行推进 time 并 Evaluate 刷新
/// - 提供一些热键用于快速测试(可按需删除)
/// </summary>
[DisallowMultipleComponent]
[RequireComponent(typeof(PlayableDirector))]
public class PlayableDirectorControls : MonoBehaviour
{
#region Inspector Fields / State
/// <summary>
/// 场景中的 PlayableDirector 引用(未手动指定时会自动 GetComponent)。
/// </summary>
[SerializeField] private PlayableDirector playableDirector;
/// <summary>
/// Director 的时间更新模式:GameTime / UnscaledGameTime / Manual 等。
/// </summary>
[Header("Update Mode(director.timeUpdateMode)")] [SerializeField]
private DirectorUpdateMode directorTimeUpdateMode = DirectorUpdateMode.GameTime;
/// <summary>
/// Director 的超出范围处理:Hold / Loop / None。
/// </summary>
[Header("Wrap Mode(director.extrapolationMode)")] [SerializeField]
private DirectorWrapMode directorExtrapolationWrapMode = DirectorWrapMode.Hold;
/// <summary>
/// Manual 模式的推进速度倍率(1=正常速度,2=两倍,-1=倒放)。
/// </summary>
[Header("Manual 模式驱动(timeUpdateMode = Manual 时生效)")] [SerializeField]
private float manualTimeSpeedMultiplier = 1f;
/// <summary>
/// Manual 模式下是否“只在 Playing 状态才推进 time”。
/// true:Pause/Stop 时不推进(推荐);false:即使暂停也推进(少见需求)。
/// </summary>
[SerializeField] private bool manualDriveOnlyWhenPlaying = true;
/// <summary>
/// Manual 推进时使用哪个 deltaTime。
/// true:Time.unscaledDeltaTime(不受 timeScale 影响);false:Time.deltaTime(受 timeScale 影响)。
/// </summary>
[SerializeField] private bool manualUseUnscaledDeltaTime = true;
/// <summary>
/// 按帧定位:time = frame / fps。
/// </summary>
[Header("按帧定位(time = frame / fps)")] [SerializeField]
private int seekTargetFrame = 30;
/// <summary>
/// 用于“按帧定位”的 fps(与 Timeline 的显示 fps 无强制关联,仅用于计算)。
/// </summary>
[SerializeField] private double seekFramesPerSecond = 60.0;
/// <summary>
/// 快捷键跳时间使用的秒数(正/负都可以)。
/// </summary>
[Header("跳时间(秒)")] [SerializeField] private double seekJumpTimeSeconds = 1.5;
/// <summary>
/// Manual 模式下用于“延后到 LateUpdate 再 Evaluate”的标记。
/// 目的:避免在 Update 里 Evaluate 后,被 Animator 自己的更新覆盖,导致看起来没切到 run/attack。
/// </summary>
private bool manualEvaluateInLateUpdateRequested;
#endregion
#region Unity Lifecycle
/// <summary>
/// Reset:在编辑器添加组件/点击 Reset 时自动抓取组件引用。
/// </summary>
private void Reset()
{
playableDirector = GetComponent<PlayableDirector>();
}
/// <summary>
/// Awake:运行时初始化引用并应用设置。
/// </summary>
private void Awake()
{
if (!playableDirector) playableDirector = GetComponent<PlayableDirector>();
ApplyDirectorSettings();
}
/// <summary>
/// OnValidate:在编辑器修改字段时触发,用于立即同步设置,便于观察效果。
/// </summary>
private void OnValidate()
{
if (!playableDirector) playableDirector = GetComponent<PlayableDirector>();
// 编辑器里改字段时立即同步到 Director,方便观察效果
if (playableDirector && !Application.isPlaying) ApplyDirectorSettings();
}
/// <summary>
/// OnEnable:注册 Director 生命周期事件。
/// </summary>
private void OnEnable()
{
if (!playableDirector) return;
// 监听播放生命周期(适合接 UI/状态机/收尾清理)
playableDirector.played += OnDirectorPlayed;
playableDirector.paused += OnDirectorPaused;
playableDirector.stopped += OnDirectorStopped;
}
/// <summary>
/// OnDisable:注销 Director 生命周期事件。
/// </summary>
private void OnDisable()
{
if (!playableDirector) return;
playableDirector.played -= OnDirectorPlayed;
playableDirector.paused -= OnDirectorPaused;
playableDirector.stopped -= OnDirectorStopped;
}
/// <summary>
/// Update:
/// - Manual 模式下手动推进 time 并 Evaluate
/// - 提供一些热键用于测试(1/2/3/4/5/6/8)
/// </summary>
private void Update()
{
// 没有 Director 或没绑定 Timeline 资产时不工作
if (!playableDirector || playableDirector.playableAsset == null) return;
// Manual 模式:Unity 不会自动推进 time,需要你自己推进并 Evaluate 刷新一帧
// 注意:这里不直接 Evaluate,而是把 Evaluate 推迟到 LateUpdate(见 manualEvaluateInLateUpdateRequested)
if (playableDirector.timeUpdateMode == DirectorUpdateMode.Manual)
DriveDirectorInManualMode();
// 下面是演示用的热键控制
if (Input.GetKeyDown(KeyCode.Alpha1)) PlayDirector();
if (Input.GetKeyDown(KeyCode.Alpha2)) playableDirector.Pause();
if (Input.GetKeyDown(KeyCode.Alpha3)) playableDirector.Stop();
if (Input.GetKeyDown(KeyCode.Alpha4)) playableDirector.Resume();
if (Input.GetKeyDown(KeyCode.Alpha5))
playableDirector.RebuildGraph(); // 重建图(重操作:通常用于换 playableAsset/大改 binding 后)
// 跳时间:改 time 后立刻 Evaluate,马上刷新到目标时刻
if (Input.GetKeyDown(KeyCode.Alpha6))
SeekDirectorTime(seekJumpTimeSeconds, evaluate: true);
// 按帧定位:time = frame / fps,再 Evaluate
if (Input.GetKeyDown(KeyCode.Alpha8))
{
if (seekFramesPerSecond <= 0.0) seekFramesPerSecond = 60.0;
SeekDirectorTime(seekTargetFrame / seekFramesPerSecond, evaluate: true);
}
}
/// <summary>
/// LateUpdate:在 Manual 模式下统一执行 Evaluate。
/// 这样可以让 Timeline 的动画采样发生在更靠后的位置,避免被 Animator Controller 的更新覆盖。
/// </summary>
private void LateUpdate()
{
if (!manualEvaluateInLateUpdateRequested) return;
if (!playableDirector || playableDirector.playableAsset == null) return;
if (playableDirector.timeUpdateMode != DirectorUpdateMode.Manual) return;
EnsurePlayableGraphIsValid();
playableDirector.Evaluate();
manualEvaluateInLateUpdateRequested = false;
}
#endregion
#region Director Settings
/// <summary>
/// 把 Inspector 配置同步到 PlayableDirector。
/// </summary>
private void ApplyDirectorSettings()
{
playableDirector.timeUpdateMode = directorTimeUpdateMode;
playableDirector.extrapolationMode = directorExtrapolationWrapMode;
}
/// <summary>
/// Director.played 事件回调(示例:打印日志)。
/// </summary>
private static void OnDirectorPlayed(PlayableDirector director) => Debug.Log($"Timeline played: {director.name}");
/// <summary>
/// Director.paused 事件回调(示例:打印日志)。
/// </summary>
private static void OnDirectorPaused(PlayableDirector director) => Debug.Log($"Timeline paused: {director.name}");
/// <summary>
/// Director.stopped 事件回调(示例:打印日志)。
/// </summary>
private static void OnDirectorStopped(PlayableDirector director) => Debug.Log($"Timeline stopped: {director.name}");
#endregion
#region Manual Drive / Seek Helpers
/// <summary>
/// 确保 playableGraph 有效:Stop 后 graph 可能无效,必要时重建。
/// </summary>
private void EnsurePlayableGraphIsValid()
{
// Stop 后 graph 可能无效;用 IsValid 做兜底,必要时重建
if (!playableDirector.playableGraph.IsValid())
playableDirector.RebuildGraph();
}
/// <summary>
/// 根据 extrapolationMode 处理 time:
/// - Loop:循环回绕
/// - Hold/None:安全夹紧到 [0, duration]
/// </summary>
/// <param name="timeSeconds">输入时间(秒)。</param>
/// <returns>处理后的安全时间(秒)。</returns>
private double ApplyDirectorExtrapolationWrapMode(double timeSeconds)
{
// 注意:在 Manual 模式下,PlayableDirector.duration 可能在图未完整建立/评估前暂时为 0
// 这会导致 time 被“夹死在 0”,表现为 Timeline 永远停在第一帧(看起来 run/attack 没进来)。
// 兜底策略:优先用 director.duration,若为 0 则回退到 playableAsset.duration。
double timelineDurationSeconds = playableDirector.duration;
if (timelineDurationSeconds <= 0.0 && playableDirector.playableAsset != null)
timelineDurationSeconds = playableDirector.playableAsset.duration;
// 仍拿不到 duration:不做 Wrap/Clamp,直接返回输入时间,至少保证能推进播放。
if (timelineDurationSeconds <= 0.0)
return timeSeconds;
// Loop:超出范围就回绕;Hold/None:做安全夹紧(避免 time 越界)
if (playableDirector.extrapolationMode == DirectorWrapMode.Loop)
{
timeSeconds %= timelineDurationSeconds;
if (timeSeconds < 0) timeSeconds += timelineDurationSeconds;
return timeSeconds;
}
return ClampDouble(timeSeconds, 0.0, timelineDurationSeconds);
}
/// <summary>
/// Manual 模式驱动:按 dt * manualSpeed 推进 time,并 Evaluate 刷新一帧。
/// </summary>
private void DriveDirectorInManualMode()
{
// driveOnlyWhenPlaying=true:Pause/Stop 时不推进,避免“暂停也在走”的困惑
if (manualDriveOnlyWhenPlaying && playableDirector.state != PlayState.Playing)
return;
EnsurePlayableGraphIsValid();
double deltaTimeSeconds = manualUseUnscaledDeltaTime ? Time.unscaledDeltaTime : Time.deltaTime;
double nextTimeSeconds = playableDirector.time + deltaTimeSeconds * manualTimeSpeedMultiplier;
playableDirector.time = ApplyDirectorExtrapolationWrapMode(nextTimeSeconds);
// Manual 模式下把 Evaluate 延后到 LateUpdate,保证最终姿势不被 Animator 覆盖
manualEvaluateInLateUpdateRequested = true;
}
/// <summary>
/// 播放:必要时先确保 graph 有效(适合“先 Seek 再 Play”的用法)。
/// </summary>
private void PlayDirector()
{
// Play 会构建 graph;这里 Ensure 一下是为了 “先 Seek 再 Play” 时更稳
EnsurePlayableGraphIsValid();
playableDirector.Play();
}
/// <summary>
/// 设置 Director.time,并按需 Evaluate 立即刷新到目标时刻。
/// </summary>
/// <param name="targetTimeSeconds">目标时间(秒)。</param>
/// <param name="evaluate">是否立即 Evaluate 刷新一帧。</param>
private void SeekDirectorTime(double targetTimeSeconds, bool evaluate)
{
EnsurePlayableGraphIsValid();
playableDirector.time = ApplyDirectorExtrapolationWrapMode(targetTimeSeconds);
if (!evaluate) return;
// Manual 模式下把 Evaluate 延后到 LateUpdate,保证最终姿势不被 Animator 覆盖
if (playableDirector.timeUpdateMode == DirectorUpdateMode.Manual)
{
manualEvaluateInLateUpdateRequested = true;
return;
}
playableDirector.Evaluate();
}
#endregion
#region Utility
/// <summary>
/// double 版 Clamp(兼容旧 Unity 运行时,避免使用 System.Math.Clamp)。
/// </summary>
/// <param name="value">输入值。</param>
/// <param name="min">下限。</param>
/// <param name="max">上限。</param>
/// <returns>夹紧后的值。</returns>
private static double ClampDouble(double value, double min, double max)
{
if (value < min) return min;
if (value > max) return max;
return value;
}
#endregion
}
运行时绑定:Set / GetGenericBinding 的正确打开方式
PlayableDirector 专门提供了一套“绑定表 API”来处理 Timeline 轨道绑定:
SetGenericBinding(Object key, Object value)GetGenericBinding(Object key)ClearGenericBinding(Object key)(可选,但非常实用)
核心概念可以按“剧本/导演”的比喻来理解,先把三者的关系摆正:
- TimelineAsset(剧本):只是一份资产,它自己不会去场景里找对象。
- 它提供一个属性:
outputs,类型是IEnumerable<PlayableBinding>(可以直接理解成PlayableBinding的集合,能foreach遍历)。 - 这组
outputs的含义是:这份剧本里有哪些轨道需要绑定对象,分别需要什么类型。
- 它提供一个属性:
- PlayableBinding(绑定需求):就是
outputs里的一条记录。- 它会说明:是哪一条轨道/输出在要人(
sourceObject),以及 需要什么岗位的演员(outputTargetType)。 - 可以把它想成剧本里的一行备注:
某条轨道 -> 需要 Animator / AudioSource ...
- 它会说明:是哪一条轨道/输出在要人(
- PlayableDirector(导演):运行时入口,负责把“场景里的演员”分配给“剧本里的需求”。
- 也就是:遍历
playableDirector.playableAsset.outputs,拿到每个PlayableBinding,再用SetGenericBinding(key, value)把对象绑上。
- 也就是:遍历
理解了 PlayableBinding,再看PlayableDirector绑定表:
PlayableDirector内部维护一张 绑定表:key -> value- key:某个
PlayableBinding.sourceObject(Timeline 资产里那条轨道/输出对应的对象引用,通常就是某个 TrackAsset / MarkerTrack)。简单理解:key 就是“轨道本身”(更准确说是轨道对象引用)。 - value:要绑定到这条轨道上的场景对象(Animator / AudioSource / SignalReceiver 等)。简单理解:value 就是“这条轨道要用的绑定对象”。
- key:某个
TimelineAsset.outputs(PlayableBinding集合) 会把“有哪些 key、每个 key 需要什么类型的 value”都列出来。每个 PlayableBinding 这个类重点看三项:
sourceObject:这条输出对应的轨道对象(拿它当 key,一般就是轨道对象引用)outputTargetType:这条输出期望绑定的目标类型(用它决定绑 Animator 还是 AudioSource)streamName:编辑器里显示的名字(适合打日志;不要当逻辑 key)
遍历outputs比“按 trackName 扫一遍再绑定”的优势:
- 资源改名、轨道改名、轨道顺序调整都不影响(因为 key 是对象引用,不是字符串)
- Get 能让你“读出当前绑定”,避免重复 Set、也方便打日志定位绑定缺失
- 绑定逻辑可以按
outputTargetType做“类型分发”,协作时不容易因为重命名/结构调整导致绑定逻辑碎片化
遍历 outputs 集合 + 先 Get 再 Set
using System;
using UnityEngine;
using UnityEngine.Playables;
using Object = UnityEngine.Object;
/// <summary>
/// Timeline “通用绑定(Generic Binding)”示例:
/// 运行时遍历 Timeline 资产的所有输出轨道,根据轨道需要的绑定类型(Animator/AudioSource 等)
/// 自动调用 <see cref="PlayableDirector.SetGenericBinding"/> 绑定到你提供的目标对象。
/// </summary>
public sealed class TimelineGenericBindingDemo : MonoBehaviour
{
/// <summary>
/// 要操作的 <see cref="PlayableDirector"/>。
/// 要求:<see cref="PlayableDirector.playableAsset"/> 不为空(挂了一个 Timeline 资产)。
/// </summary>
[SerializeField]
private PlayableDirector playableDirector;
/// <summary>
/// 示例绑定目标:当轨道需要 <see cref="Animator"/> 时绑定到它。
/// </summary>
[Header("示例绑定目标(按轨道需要的类型提供)")]
[SerializeField]
private Animator animator;
/// <summary>
/// 示例绑定目标:当轨道需要 <see cref="AudioSource"/> 时绑定到它。
/// </summary>
[SerializeField]
private AudioSource audioSource;
/// <summary>
/// Awake:在运行时做一次“按类型自动绑定”。
/// 说明:这只是演示写法;实际项目里也可以放在 Start/OnEnable,或在切换 Timeline 资产时重新绑定。
/// </summary>
private void Awake()
{
if (!playableDirector || playableDirector.playableAsset == null) return;
foreach (PlayableBinding playableBinding in playableDirector.playableAsset.outputs)
{
// sourceObj:真正用于 Get/SetGenericBinding 的 key
// 它就是该输出对应的 Track / MarkerTrack 对象本体(不是名字字符串)。
Object sourceObj = playableBinding.sourceObject;
// outputTargetType:这个输出希望绑定的目标类型(例如 Animator / AudioSource)
Type outputTargetType = playableBinding.outputTargetType;
// 1) 先读当前绑定(便于增量更新 & Debug)
Object currentBindingObj = playableDirector.GetGenericBinding(sourceObj);
// 2) 根据轨道需要的类型,决定要绑定哪个“演员”(你在 Inspector 提供的对象)
Object targetBindingObj = GetTargetBindingObj(outputTargetType);
// 3) 按需更新:目标为空就跳过;目标不一样才 Set
if (targetBindingObj == null)
{
// 轨道需要的类型你没提供:建议至少打个日志,排查“为什么没播放/没触发”
// Debug.LogWarning($"[TimelineBind] Missing target for '{playableBinding.streamName}' need={outputTargetType?.Name}");
continue;
}
if (currentBindingObj == targetBindingObj)
continue; // 已经绑定正确:不用重复 Set
// 4) 绑定到新的目标
playableDirector.SetGenericBinding(sourceObj, targetBindingObj);
Debug.Log(
$"[TimelineBind] stream='{playableBinding.streamName}', " +
$"sourceObj='{(sourceObj != null ? sourceObj.name : "null")}'({(sourceObj != null ? sourceObj.GetType().Name : "null")}), " +
$"needType='{(outputTargetType != null ? outputTargetType.Name : "null")}', " +
$"current='{(currentBindingObj != null ? currentBindingObj.name : "null")}'({(currentBindingObj != null ? currentBindingObj.GetType().Name : "null")}), " +
$"target='{(targetBindingObj != null ? targetBindingObj.name : "null")}'({(targetBindingObj != null ? targetBindingObj.GetType().Name : "null")})"
);
}
}
/// <summary>
/// 根据轨道期望的绑定类型,返回你在 Inspector 提供的目标对象。
/// </summary>
/// <param name="needType">轨道输出期望绑定的类型。</param>
/// <returns>可用于 SetGenericBinding 的目标对象;如果没有匹配则返回 null。</returns>
private Object GetTargetBindingObj(System.Type needType)
{
if (needType == typeof(Animator)) return animator;
if (needType == typeof(AudioSource)) return audioSource;
return null;
}
}
这段绑定代码里,有几个点建议固定成习惯:
用
PlayableBinding.sourceObject做 key:稳定,不依赖轨道名字符串**先
GetGenericBinding再SetGenericBinding**:更容易做增量更新- 避免重复 Set(日志也更干净)
- 排查问题时能直接看到“当前到底绑了谁”
运行接管


什么时候需要 RebuildGraph / Rebind / Clear
只改绑定(SetGenericBinding):大多数情况下不需要重建图,尤其是 在 Play 之前设置。
运行时新增/删除了组件(例如运行时给对象加了 Animator/AudioSource/NotificationReceiver):
用RebindPlayableGraphOutputs()更合适,它会刷新输出绑定信息,不必整图重建。你想彻底移除某条轨道的依赖关系:
ClearGenericBinding(key)会清掉绑定,并把这条 key 从“绑定表”里移除;而SetGenericBinding(key, null)更像是“这条轨道我还是要管,但当前不给它演员”,key 仍然留在表里(两者语义不一样)。ClearGenericBinding(key):把这一项 彻底删掉。后续 Director/Timeline 在做绑定相关处理时,会把它当成“没有这条绑定记录”。SetGenericBinding(key, null):这一项 还在,只是 value 为空。后续处理时仍然知道“这条轨道需要一个对象”,只是现在没提供,运行时更容易出现“该轨道不生效/提示缺少绑定”的情况。
小技巧:把 streamName 当 Debug 标签,而不是逻辑条件
streamName适合打印日志:轨道显示名 + 需要类型 + 当前绑定- 但不建议用它做判断:字符串在改名/重构/合并资源时最容易失效
使用 ExposedReference 让 PlayableAsset上的Clip在Inspector窗口 安全引用 Scene 对象
轨道能绑 Scene、Clip 却不行的原因:场景引用到底存在哪
- TimelineAsset(剧本)在 Project 里,是一份可复用的配置;
Clip/PlayableAsset的字段最终都会序列化到这份剧本资产里。 - PlayableDirector(导演)在 Scene/Prefab 里,是运行时入口;它既负责跑 Timeline,也负责在运行时把“演员”分配给“剧本里的需求”。
- Scene 里的
GameObject/Component才是运行时的“演员”。
Timeline 里常见现象:轨道绑定对象时能拖场景对象,Clip Inspector 却拖不进去。关键差别是:引用最终存在哪个对象上不一样。
- 轨道为什么能绑定场景对象:
- 轨道(Track)在 TimelineAsset(剧本)里只声明“我需要哪类演员”(比如 Animator / AudioSource)。
- 当在 Timeline 窗口给轨道拖一个场景对象时,Unity 实际是在给 PlayableDirector(导演)的绑定表写入一条记录:
(轨道对象 sourceObject) -> (场景对象 value)。 - 也就是说:轨道本身没有把场景引用写进剧本资产,引用是导演(Scene/Prefab 里的组件)持有的,所以同一份 TimelineAsset 放到不同场景,可以由不同导演绑定不同演员。
- Clip 字段为什么不能直接引用场景对象:
- Clip/PlayableAsset 的字段是剧本资产的一部分(会被序列化到 Project 里的
.playable/.asset资源里)。 - 如果允许把 Scene 里的对象直接拖进 Clip 字段,本质就是“剧本(资产)直接引用演员(场景对象)”。这会立刻带来两个典型问题:
- 同一个 TimelineAsset 在 A 场景引用了 A 的对象,拷贝到 B 场景时引用天然无效(对象不在这个场景里)。
- 资产生命周期长于场景对象,资源迁移/重命名/Prefab 化时更容易产生断引用。
- 所以 Unity 的策略是:资产字段只稳定引用资产(例如 Prefab/ScriptableObject/AnimationClip),不直接引用 Scene 对象。
- Clip/PlayableAsset 的字段是剧本资产的一部分(会被序列化到 Project 里的
Timeline 里常见的处理方式有两类:
- **
ExposedReference<T>**:剧本里只存一个“占位”,运行时由PlayableDirector(实现了IExposedPropertyTable)解析成真实对象。 - 走轨道绑定传递:把对象放在轨道绑定(
GenericBinding)里,让 Clip 在运行时通过 binding/playerData 拿到它,避免 Clip 直接持有场景引用。
ExposedReference源码解析
ExposedReference<T> 的定位很明确:PlayableAsset 侧不直接保存 Scene 引用,而是保存一个可序列化的 key;运行时再借助 PlayableDirector(IExposedPropertyTable)把 key 映射回真实对象。
这段结构体里关键字段/方法如下:
- **
exposedName**:PropertyName,占位引用的 key(写进资产的是它,而不是 Scene 对象本体)。 - **
defaultValue**:默认引用。解析失败时回退到它。 - **
Resolve(IExposedPropertyTable resolver)**:解析函数。resolver常见来源是graph.GetResolver(),在 Timeline 场景里一般就是PlayableDirector。
把这条链路串起来会更直观:
PlayableDirector : Behaviour, IExposedPropertyTable,也就是说导演身上就带着一张“exposedName -> Object”的映射表。PlayableGraph.GetResolver()返回的是一个IExposedPropertyTable,在 Timeline 的使用场景里通常就是当前的PlayableDirector。- 因此
ExposedReference<T>.Resolve(resolver)里传入的resolver,一般就是PlayableDirector(或者等价实现)。
using System;
using UnityEngine.Scripting;
#nullable disable
namespace UnityEngine;
/// <summary>
/// <para>Creates a type whos value is resolvable at runtime.</para>
/// </summary>
[UsedByNativeCode(Name = "ExposedReference")]
[Serializable]
public struct ExposedReference<T> where T : Object
{
[SerializeField]
public PropertyName exposedName;
[SerializeField]
public Object defaultValue;
public T Resolve(IExposedPropertyTable resolver)
{
if (resolver != null)
{
bool idValid;
Object referenceValue = resolver.GetReferenceValue(this.exposedName, out idValid);
if (idValid)
return referenceValue as T;
}
return this.defaultValue as T;
}
}
Resolve() 的逻辑就是一次“查表 + 回退”:
- 用
exposedName调resolver.GetReferenceValue(),通过idValid判断该 key 是否有有效绑定。 - 绑定存在则返回绑定对象(
as T)。 - 否则返回
defaultValue(同样按T转换)。
ExposedReference使用实战自定义轨道
目标
- 在 Clip 的 Inspector 里选一个 Scene 里的
Transform作为目标点,Timeline 播放时把轨道绑定的GameObject移动到targetTransform.position + positionOffset;离开片段时把物体的位置还原。
- 在 Clip 的 Inspector 里选一个 Scene 里的
场景准备
Target:提供Transform(目标点)。Cube:被驱动的物体(轨道 Binding 绑定它)。


- 代码实现
using UnityEngine;
using UnityEngine.Timeline;
/// <summary>
/// 自定义轨道(Track):
/// - 轨道里可以创建 <see cref="ExposedReferenceTestClip"/> 片段
/// - 轨道 Binding 类型为 <see cref="GameObject"/>(把要被驱动的物体拖到轨道 Binding)
/// </summary>
[TrackColor(0.25f, 0.6f, 0.95f)]
[TrackClipType(typeof(ExposedReferenceTestClip))]
[TrackBindingType(typeof(GameObject))]
public sealed class ExposedReferenceTestTrack : TrackAsset
{
}
using UnityEngine;
using UnityEngine.Playables;
/// <summary>
/// Timeline 片段资产(Clip):
/// - 在 Inspector 上保存参数
/// - 通过 <see cref="ExposedReference{T}"/> 保存 Scene 对象引用(运行时由 PlayableDirector 解析)
/// - 在 <see cref="CreatePlayable"/> 中把数据拷贝到 <see cref="ExposedReferenceTestBehaviour"/>
/// </summary>
public sealed class ExposedReferenceTestClip : PlayableAsset
{
/// <summary>
/// Scene 对象引用(例如场景里的某个 Transform)。
/// 说明:Scene 对象不能直接序列化到 PlayableAsset 里,需要用 ExposedReference 占位引用。
/// </summary>
public ExposedReference<Transform> targetTransform;
/// <summary>
/// 位置偏移(世界坐标)。
/// </summary>
public Vector3 positionOffset;
/// <summary>
/// 创建该片段对应的 Playable,并把参数传递给 Behaviour。
/// </summary>
/// <param name="graph">PlayableGraph。</param>
/// <param name="owner">拥有者对象(通常是 PlayableDirector 所在对象)。</param>
/// <returns>该片段对应的 Playable。</returns>
public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
{
// 1) 创建一个可承载自定义 Behaviour 的 ScriptPlayable(此时 Behaviour 还是默认值)
ScriptPlayable<ExposedReferenceTestBehaviour> exposedReferenceTestPlayable =
// Create(graph) 会在图里分配一个节点,并附带一个 ExposedReferenceTestBehaviour 实例
ScriptPlayable<ExposedReferenceTestBehaviour>.Create(graph);
// 2) 拿到该 ScriptPlayable 上的 Behaviour 实例,用于把 Inspector 数据拷贝进去
ExposedReferenceTestBehaviour exposedReferenceTestBehaviour = exposedReferenceTestPlayable.GetBehaviour();
// 3) Resolver 用来解析 ExposedReference
// Timeline 场景里,一般就是 PlayableDirector:
// - PlayableDirector 实现了 IExposedPropertyTable(内部维护 exposedName -> Object 的映射表)
// - graph.GetResolver() 返回的就是这个 IExposedPropertyTable 实例
// - 因此 ExposedReference<T>.Resolve(resolver) 通常传入的就是 PlayableDirector(或等价的 resolver)
IExposedPropertyTable exposedPropertyTable = graph.GetResolver();
// 4) 解析 Scene 引用:把“占位引用”变成真正的 Transform(依赖 Director 上的 ExposedReference 绑定表)
exposedReferenceTestBehaviour.targetTransform = targetTransform.Resolve(exposedPropertyTable);
// 5) 普通值类型直接拷贝即可(它不需要 Resolve)
exposedReferenceTestBehaviour.positionOffset = positionOffset;
// 6) 把创建好的 Playable 返回给 Timeline,后续由 Timeline 驱动该 Behaviour 的回调(如 ProcessFrame)
return exposedReferenceTestPlayable;
}
}
using UnityEngine;
using UnityEngine.Playables;
/// <summary>
/// Timeline 片段的运行时行为:
/// - 从轨道 Binding 里拿到要控制的 GameObject
/// - 从 <see cref="ExposedReferenceTestClip"/> 解析得到 Scene 里的目标 Transform
/// - 每帧把被控物体的位置设置为:targetTransform.position + positionOffset
/// </summary>
public sealed class ExposedReferenceTestBehaviour : PlayableBehaviour
{
/// <summary>
/// Scene 对象引用:由 <see cref="ExposedReferenceTestClip"/> 通过 ExposedReference.Resolve 解析后赋值。
/// </summary>
public Transform targetTransform;
/// <summary>
/// 位置偏移(世界坐标)。
/// </summary>
public Vector3 positionOffset;
/// <summary>
/// 进入该片段时记录的“原始世界坐标”。
/// </summary>
private Vector3 _enterWorldPosition;
/// <summary>
/// 是否已经捕获过进入时的原始坐标(保证只记录一次)。
/// </summary>
private bool _hasCapturedEnterWorldPosition;
/// <summary>
/// 缓存轨道 Binding 注入的对象(也就是被驱动的 GameObject),用于在退出回调中恢复坐标。
/// </summary>
private GameObject _boundGameObject;
/// <summary>
/// 上一次回调时的权重(用于区分“离开片段”与“片段内暂停”)。
/// </summary>
private float _lastFrameWeight;
/// <summary>
/// Timeline 每帧评估回调:根据 Binding + ExposedReference 结果驱动目标物体。
/// </summary>
/// <param name="playable">当前片段的 Playable。</param>
/// <param name="info">帧信息。</param>
/// <param name="playerData">轨道 Binding 注入对象(本轨道为 GameObject)。</param>
public override void ProcessFrame(Playable playable, FrameData info, object playerData)
{
_lastFrameWeight = info.weight;
GameObject boundGameObject = playerData as GameObject;
if (boundGameObject == null || targetTransform == null)
return;
// 缓存绑定对象:退出片段时要用它恢复坐标。
_boundGameObject = boundGameObject;
// 第一次真正影响输出时(weight > 0),记录进入片段前的原始坐标。
// 说明:只有 ProcessFrame 能拿到 playerData,所以“捕获原始坐标”放这里最稳。
if (!_hasCapturedEnterWorldPosition && info.weight > 0f)
{
_hasCapturedEnterWorldPosition = true;
_enterWorldPosition = boundGameObject.transform.position;
}
boundGameObject.transform.position = targetTransform.position + positionOffset;
}
/// <summary>
/// 片段退出回调:恢复进入片段前的原始坐标。
/// </summary>
public override void OnBehaviourPause(Playable playable, FrameData info)
{
_lastFrameWeight = info.weight;
// 只在“离开片段”(权重归 0)时恢复。
// 如果只是暂停且仍在片段范围内(权重仍 > 0),保持当前位置,保证编辑器所见即所得。
if (info.weight <= 0f)
RestoreEnterWorldPositionIfNeeded();
}
/// <summary>
/// Graph 停止回调:兜底恢复进入片段前的原始坐标。
/// </summary>
public override void OnGraphStop(Playable playable)
{
// Graph 停止时也遵循“只在不在片段范围内才恢复”的规则:
// - 如果最后一次权重仍 > 0(一般表示停在片段内部),保持当前位置。
// - 否则恢复进入片段前的位置。
if (_lastFrameWeight <= 0f)
RestoreEnterWorldPositionIfNeeded();
}
/// <summary>
/// 若已记录进入坐标,则恢复并清理缓存状态。
/// </summary>
private void RestoreEnterWorldPositionIfNeeded()
{
if (!_hasCapturedEnterWorldPosition)
return;
if (_boundGameObject != null)
_boundGameObject.transform.position = _enterWorldPosition;
_hasCapturedEnterWorldPosition = false;
_boundGameObject = null;
}
}
- Timeline 绑定
ExposedReferenceTestTrack的 Binding 类型声明为GameObject,所以ProcessFrame(..., object playerData)里拿到的就是这个绑定对象。- Director 的 Bindings 面板里把该轨道绑定到
Cube。

- Clip 配置(Inspector)
Target Transform:拖Target的 Transform(这是ExposedReference<Transform>,运行时会 Resolve)。Position Offset:额外偏移,用来把目标点“推开一点”,便于观察位移。

- 运行效果
- 播放到该片段时,
Cube的位置会被写成Target.position + Offset(下图能看到 Transform 数值变化)。
- 播放到该片段时,

3.2 知识点代码
PlayableDirectorControls.cs
using System;
using UnityEngine;
using UnityEngine.Playables;
/// <summary>
/// PlayableDirector 的示例控制器:
/// - 演示 timeUpdateMode / extrapolationMode 的常用配置
/// - 支持 Manual 模式下自行推进 time 并 Evaluate 刷新
/// - 提供一些热键用于快速测试(可按需删除)
/// </summary>
[DisallowMultipleComponent]
[RequireComponent(typeof(PlayableDirector))]
public class PlayableDirectorControls : MonoBehaviour
{
#region Inspector Fields / State
/// <summary>
/// 场景中的 PlayableDirector 引用(未手动指定时会自动 GetComponent)。
/// </summary>
[SerializeField] private PlayableDirector playableDirector;
/// <summary>
/// Director 的时间更新模式:GameTime / UnscaledGameTime / Manual 等。
/// </summary>
[Header("Update Mode(director.timeUpdateMode)")] [SerializeField]
private DirectorUpdateMode directorTimeUpdateMode = DirectorUpdateMode.GameTime;
/// <summary>
/// Director 的超出范围处理:Hold / Loop / None。
/// </summary>
[Header("Wrap Mode(director.extrapolationMode)")] [SerializeField]
private DirectorWrapMode directorExtrapolationWrapMode = DirectorWrapMode.Hold;
/// <summary>
/// Manual 模式的推进速度倍率(1=正常速度,2=两倍,-1=倒放)。
/// </summary>
[Header("Manual 模式驱动(timeUpdateMode = Manual 时生效)")] [SerializeField]
private float manualTimeSpeedMultiplier = 1f;
/// <summary>
/// Manual 模式下是否“只在 Playing 状态才推进 time”。
/// true:Pause/Stop 时不推进(推荐);false:即使暂停也推进(少见需求)。
/// </summary>
[SerializeField] private bool manualDriveOnlyWhenPlaying = true;
/// <summary>
/// Manual 推进时使用哪个 deltaTime。
/// true:Time.unscaledDeltaTime(不受 timeScale 影响);false:Time.deltaTime(受 timeScale 影响)。
/// </summary>
[SerializeField] private bool manualUseUnscaledDeltaTime = true;
/// <summary>
/// 按帧定位:time = frame / fps。
/// </summary>
[Header("按帧定位(time = frame / fps)")] [SerializeField]
private int seekTargetFrame = 30;
/// <summary>
/// 用于“按帧定位”的 fps(与 Timeline 的显示 fps 无强制关联,仅用于计算)。
/// </summary>
[SerializeField] private double seekFramesPerSecond = 60.0;
/// <summary>
/// 快捷键跳时间使用的秒数(正/负都可以)。
/// </summary>
[Header("跳时间(秒)")] [SerializeField] private double seekJumpTimeSeconds = 1.5;
/// <summary>
/// Manual 模式下用于“延后到 LateUpdate 再 Evaluate”的标记。
/// 目的:避免在 Update 里 Evaluate 后,被 Animator 自己的更新覆盖,导致看起来没切到 run/attack。
/// </summary>
private bool manualEvaluateInLateUpdateRequested;
#endregion
#region Unity Lifecycle
/// <summary>
/// Reset:在编辑器添加组件/点击 Reset 时自动抓取组件引用。
/// </summary>
private void Reset()
{
playableDirector = GetComponent<PlayableDirector>();
}
/// <summary>
/// Awake:运行时初始化引用并应用设置。
/// </summary>
private void Awake()
{
if (!playableDirector) playableDirector = GetComponent<PlayableDirector>();
ApplyDirectorSettings();
}
/// <summary>
/// OnValidate:在编辑器修改字段时触发,用于立即同步设置,便于观察效果。
/// </summary>
private void OnValidate()
{
if (!playableDirector) playableDirector = GetComponent<PlayableDirector>();
// 编辑器里改字段时立即同步到 Director,方便观察效果
if (playableDirector && !Application.isPlaying) ApplyDirectorSettings();
}
/// <summary>
/// OnEnable:注册 Director 生命周期事件。
/// </summary>
private void OnEnable()
{
if (!playableDirector) return;
// 监听播放生命周期(适合接 UI/状态机/收尾清理)
playableDirector.played += OnDirectorPlayed;
playableDirector.paused += OnDirectorPaused;
playableDirector.stopped += OnDirectorStopped;
}
/// <summary>
/// OnDisable:注销 Director 生命周期事件。
/// </summary>
private void OnDisable()
{
if (!playableDirector) return;
playableDirector.played -= OnDirectorPlayed;
playableDirector.paused -= OnDirectorPaused;
playableDirector.stopped -= OnDirectorStopped;
}
/// <summary>
/// Update:
/// - Manual 模式下手动推进 time 并 Evaluate
/// - 提供一些热键用于测试(1/2/3/4/5/6/8)
/// </summary>
private void Update()
{
// 没有 Director 或没绑定 Timeline 资产时不工作
if (!playableDirector || playableDirector.playableAsset == null) return;
// Manual 模式:Unity 不会自动推进 time,需要你自己推进并 Evaluate 刷新一帧
// 注意:这里不直接 Evaluate,而是把 Evaluate 推迟到 LateUpdate(见 manualEvaluateInLateUpdateRequested)
if (playableDirector.timeUpdateMode == DirectorUpdateMode.Manual)
DriveDirectorInManualMode();
// 下面是演示用的热键控制
if (Input.GetKeyDown(KeyCode.Alpha1)) PlayDirector();
if (Input.GetKeyDown(KeyCode.Alpha2)) playableDirector.Pause();
if (Input.GetKeyDown(KeyCode.Alpha3)) playableDirector.Stop();
if (Input.GetKeyDown(KeyCode.Alpha4)) playableDirector.Resume();
if (Input.GetKeyDown(KeyCode.Alpha5))
playableDirector.RebuildGraph(); // 重建图(重操作:通常用于换 playableAsset/大改 binding 后)
// 跳时间:改 time 后立刻 Evaluate,马上刷新到目标时刻
if (Input.GetKeyDown(KeyCode.Alpha6))
SeekDirectorTime(seekJumpTimeSeconds, evaluate: true);
// 按帧定位:time = frame / fps,再 Evaluate
if (Input.GetKeyDown(KeyCode.Alpha8))
{
if (seekFramesPerSecond <= 0.0) seekFramesPerSecond = 60.0;
SeekDirectorTime(seekTargetFrame / seekFramesPerSecond, evaluate: true);
}
}
/// <summary>
/// LateUpdate:在 Manual 模式下统一执行 Evaluate。
/// 这样可以让 Timeline 的动画采样发生在更靠后的位置,避免被 Animator Controller 的更新覆盖。
/// </summary>
private void LateUpdate()
{
if (!manualEvaluateInLateUpdateRequested) return;
if (!playableDirector || playableDirector.playableAsset == null) return;
if (playableDirector.timeUpdateMode != DirectorUpdateMode.Manual) return;
EnsurePlayableGraphIsValid();
playableDirector.Evaluate();
manualEvaluateInLateUpdateRequested = false;
}
#endregion
#region Director Settings
/// <summary>
/// 把 Inspector 配置同步到 PlayableDirector。
/// </summary>
private void ApplyDirectorSettings()
{
playableDirector.timeUpdateMode = directorTimeUpdateMode;
playableDirector.extrapolationMode = directorExtrapolationWrapMode;
}
/// <summary>
/// Director.played 事件回调(示例:打印日志)。
/// </summary>
private static void OnDirectorPlayed(PlayableDirector director) => Debug.Log($"Timeline played: {director.name}");
/// <summary>
/// Director.paused 事件回调(示例:打印日志)。
/// </summary>
private static void OnDirectorPaused(PlayableDirector director) => Debug.Log($"Timeline paused: {director.name}");
/// <summary>
/// Director.stopped 事件回调(示例:打印日志)。
/// </summary>
private static void OnDirectorStopped(PlayableDirector director) => Debug.Log($"Timeline stopped: {director.name}");
#endregion
#region Manual Drive / Seek Helpers
/// <summary>
/// 确保 playableGraph 有效:Stop 后 graph 可能无效,必要时重建。
/// </summary>
private void EnsurePlayableGraphIsValid()
{
// Stop 后 graph 可能无效;用 IsValid 做兜底,必要时重建
if (!playableDirector.playableGraph.IsValid())
playableDirector.RebuildGraph();
}
/// <summary>
/// 根据 extrapolationMode 处理 time:
/// - Loop:循环回绕
/// - Hold/None:安全夹紧到 [0, duration]
/// </summary>
/// <param name="timeSeconds">输入时间(秒)。</param>
/// <returns>处理后的安全时间(秒)。</returns>
private double ApplyDirectorExtrapolationWrapMode(double timeSeconds)
{
// 注意:在 Manual 模式下,PlayableDirector.duration 可能在图未完整建立/评估前暂时为 0
// 这会导致 time 被“夹死在 0”,表现为 Timeline 永远停在第一帧(看起来 run/attack 没进来)。
// 兜底策略:优先用 director.duration,若为 0 则回退到 playableAsset.duration。
double timelineDurationSeconds = playableDirector.duration;
if (timelineDurationSeconds <= 0.0 && playableDirector.playableAsset != null)
timelineDurationSeconds = playableDirector.playableAsset.duration;
// 仍拿不到 duration:不做 Wrap/Clamp,直接返回输入时间,至少保证能推进播放。
if (timelineDurationSeconds <= 0.0)
return timeSeconds;
// Loop:超出范围就回绕;Hold/None:做安全夹紧(避免 time 越界)
if (playableDirector.extrapolationMode == DirectorWrapMode.Loop)
{
timeSeconds %= timelineDurationSeconds;
if (timeSeconds < 0) timeSeconds += timelineDurationSeconds;
return timeSeconds;
}
return ClampDouble(timeSeconds, 0.0, timelineDurationSeconds);
}
/// <summary>
/// Manual 模式驱动:按 dt * manualSpeed 推进 time,并 Evaluate 刷新一帧。
/// </summary>
private void DriveDirectorInManualMode()
{
// driveOnlyWhenPlaying=true:Pause/Stop 时不推进,避免“暂停也在走”的困惑
if (manualDriveOnlyWhenPlaying && playableDirector.state != PlayState.Playing)
return;
EnsurePlayableGraphIsValid();
double deltaTimeSeconds = manualUseUnscaledDeltaTime ? Time.unscaledDeltaTime : Time.deltaTime;
double nextTimeSeconds = playableDirector.time + deltaTimeSeconds * manualTimeSpeedMultiplier;
playableDirector.time = ApplyDirectorExtrapolationWrapMode(nextTimeSeconds);
// Manual 模式下把 Evaluate 延后到 LateUpdate,保证最终姿势不被 Animator 覆盖
manualEvaluateInLateUpdateRequested = true;
}
/// <summary>
/// 播放:必要时先确保 graph 有效(适合“先 Seek 再 Play”的用法)。
/// </summary>
private void PlayDirector()
{
// Play 会构建 graph;这里 Ensure 一下是为了 “先 Seek 再 Play” 时更稳
EnsurePlayableGraphIsValid();
playableDirector.Play();
}
/// <summary>
/// 设置 Director.time,并按需 Evaluate 立即刷新到目标时刻。
/// </summary>
/// <param name="targetTimeSeconds">目标时间(秒)。</param>
/// <param name="evaluate">是否立即 Evaluate 刷新一帧。</param>
private void SeekDirectorTime(double targetTimeSeconds, bool evaluate)
{
EnsurePlayableGraphIsValid();
playableDirector.time = ApplyDirectorExtrapolationWrapMode(targetTimeSeconds);
if (!evaluate) return;
// Manual 模式下把 Evaluate 延后到 LateUpdate,保证最终姿势不被 Animator 覆盖
if (playableDirector.timeUpdateMode == DirectorUpdateMode.Manual)
{
manualEvaluateInLateUpdateRequested = true;
return;
}
playableDirector.Evaluate();
}
#endregion
#region Utility
/// <summary>
/// double 版 Clamp(兼容旧 Unity 运行时,避免使用 System.Math.Clamp)。
/// </summary>
/// <param name="value">输入值。</param>
/// <param name="min">下限。</param>
/// <param name="max">上限。</param>
/// <returns>夹紧后的值。</returns>
private static double ClampDouble(double value, double min, double max)
{
if (value < min) return min;
if (value > max) return max;
return value;
}
#endregion
}
TimelineGenericBindingDemo.cs
using System;
using UnityEngine;
using UnityEngine.Playables;
using Object = UnityEngine.Object;
/// <summary>
/// Timeline “通用绑定(Generic Binding)”示例:
/// 运行时遍历 Timeline 资产的所有输出轨道,根据轨道需要的绑定类型(Animator/AudioSource 等)
/// 自动调用 <see cref="PlayableDirector.SetGenericBinding"/> 绑定到你提供的目标对象。
/// </summary>
public sealed class TimelineGenericBindingDemo : MonoBehaviour
{
/// <summary>
/// 要操作的 <see cref="PlayableDirector"/>。
/// 要求:<see cref="PlayableDirector.playableAsset"/> 不为空(挂了一个 Timeline 资产)。
/// </summary>
[SerializeField]
private PlayableDirector playableDirector;
/// <summary>
/// 示例绑定目标:当轨道需要 <see cref="Animator"/> 时绑定到它。
/// </summary>
[Header("示例绑定目标(按轨道需要的类型提供)")]
[SerializeField]
private Animator animator;
/// <summary>
/// 示例绑定目标:当轨道需要 <see cref="AudioSource"/> 时绑定到它。
/// </summary>
[SerializeField]
private AudioSource audioSource;
/// <summary>
/// Awake:在运行时做一次“按类型自动绑定”。
/// 说明:这只是演示写法;实际项目里也可以放在 Start/OnEnable,或在切换 Timeline 资产时重新绑定。
/// </summary>
private void Awake()
{
if (!playableDirector || playableDirector.playableAsset == null) return;
foreach (PlayableBinding playableBinding in playableDirector.playableAsset.outputs)
{
// sourceObj:真正用于 Get/SetGenericBinding 的 key
// 它就是该输出对应的 Track / MarkerTrack 对象本体(不是名字字符串)。
Object sourceObj = playableBinding.sourceObject;
// outputTargetType:这个输出希望绑定的目标类型(例如 Animator / AudioSource)
Type outputTargetType = playableBinding.outputTargetType;
// 1) 先读当前绑定(便于增量更新 & Debug)
Object currentBindingObj = playableDirector.GetGenericBinding(sourceObj);
// 2) 根据轨道需要的类型,决定要绑定哪个“演员”(你在 Inspector 提供的对象)
Object targetBindingObj = GetTargetBindingObj(outputTargetType);
// 3) 按需更新:目标为空就跳过;目标不一样才 Set
if (targetBindingObj == null)
{
// 轨道需要的类型你没提供:建议至少打个日志,排查“为什么没播放/没触发”
// Debug.LogWarning($"[TimelineBind] Missing target for '{binding.streamName}' need={outputTargetType?.Name}");
continue;
}
if (currentBindingObj == targetBindingObj)
continue; // 已经绑定正确:不用重复 Set
// 4) 绑定到新的目标
playableDirector.SetGenericBinding(sourceObj, targetBindingObj);
Debug.Log(
$"[TimelineBind] stream='{playableBinding.streamName}', " +
$"sourceObj='{(sourceObj != null ? sourceObj.name : "null")}'({(sourceObj != null ? sourceObj.GetType().Name : "null")}), " +
$"needType='{(outputTargetType != null ? outputTargetType.Name : "null")}', " +
$"current='{(currentBindingObj != null ? currentBindingObj.name : "null")}'({(currentBindingObj != null ? currentBindingObj.GetType().Name : "null")}), " +
$"target='{(targetBindingObj != null ? targetBindingObj.name : "null")}'({(targetBindingObj != null ? targetBindingObj.GetType().Name : "null")})"
);
}
}
/// <summary>
/// 根据轨道期望的绑定类型,返回你在 Inspector 提供的目标对象。
/// </summary>
/// <param name="needType">轨道输出期望绑定的类型。</param>
/// <returns>可用于 SetGenericBinding 的目标对象;如果没有匹配则返回 null。</returns>
private Object GetTargetBindingObj(System.Type needType)
{
if (needType == typeof(Animator)) return animator;
if (needType == typeof(AudioSource)) return audioSource;
return null;
}
}
ExposedReferenceTestTrack.cs
using UnityEngine;
using UnityEngine.Timeline;
/// <summary>
/// 自定义轨道(Track):
/// - 轨道里可以创建 <see cref="ExposedReferenceTestClip"/> 片段
/// - 轨道 Binding 类型为 <see cref="GameObject"/>(把要被驱动的物体拖到轨道 Binding)
/// </summary>
[TrackColor(0.25f, 0.6f, 0.95f)]
[TrackClipType(typeof(ExposedReferenceTestClip))]
[TrackBindingType(typeof(GameObject))]
public sealed class ExposedReferenceTestTrack : TrackAsset
{
}
ExposedReferenceTestClip.cs
using UnityEngine;
using UnityEngine.Playables;
/// <summary>
/// Timeline 片段资产(Clip):
/// - 在 Inspector 上保存参数
/// - 通过 <see cref="ExposedReference{T}"/> 保存 Scene 对象引用(运行时由 PlayableDirector 解析)
/// - 在 <see cref="CreatePlayable"/> 中把数据拷贝到 <see cref="ExposedReferenceTestBehaviour"/>
/// </summary>
public sealed class ExposedReferenceTestClip : PlayableAsset
{
/// <summary>
/// Scene 对象引用(例如场景里的某个 Transform)。
/// 说明:Scene 对象不能直接序列化到 PlayableAsset 里,需要用 ExposedReference 占位引用。
/// </summary>
public ExposedReference<Transform> targetTransform;
/// <summary>
/// 位置偏移(世界坐标)。
/// </summary>
public Vector3 positionOffset;
/// <summary>
/// 创建该片段对应的 Playable,并把参数传递给 Behaviour。
/// </summary>
/// <param name="graph">PlayableGraph。</param>
/// <param name="owner">拥有者对象(通常是 PlayableDirector 所在对象)。</param>
/// <returns>该片段对应的 Playable。</returns>
public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
{
// 1) 创建一个可承载自定义 Behaviour 的 ScriptPlayable(此时 Behaviour 还是默认值)
ScriptPlayable<ExposedReferenceTestBehaviour> exposedReferenceTestPlayable =
// Create(graph) 会在图里分配一个节点,并附带一个 ExposedReferenceTestBehaviour 实例
ScriptPlayable<ExposedReferenceTestBehaviour>.Create(graph);
// 2) 拿到该 ScriptPlayable 上的 Behaviour 实例,用于把 Inspector 数据拷贝进去
ExposedReferenceTestBehaviour exposedReferenceTestBehaviour = exposedReferenceTestPlayable.GetBehaviour();
// 3) Resolver 用来解析 ExposedReference(一般就是 PlayableDirector,实现了 IExposedPropertyTable)
IExposedPropertyTable exposedPropertyTable = graph.GetResolver();
// 4) 解析 Scene 引用:把“占位引用”变成真正的 Transform(依赖 Director 上的 ExposedReference 绑定表)
exposedReferenceTestBehaviour.targetTransform = targetTransform.Resolve(exposedPropertyTable);
// 5) 普通值类型直接拷贝即可(它不需要 Resolve)
exposedReferenceTestBehaviour.positionOffset = positionOffset;
// 6) 把创建好的 Playable 返回给 Timeline,后续由 Timeline 驱动该 Behaviour 的回调(如 ProcessFrame)
return exposedReferenceTestPlayable;
}
}
ExposedReferenceTestBehaviour.cs
using UnityEngine;
using UnityEngine.Playables;
/// <summary>
/// Timeline 片段的运行时行为:
/// - 从轨道 Binding 里拿到要控制的 GameObject
/// - 从 <see cref="ExposedReferenceTestClip"/> 解析得到 Scene 里的目标 Transform
/// - 每帧把被控物体的位置设置为:targetTransform.position + positionOffset
/// </summary>
public sealed class ExposedReferenceTestBehaviour : PlayableBehaviour
{
/// <summary>
/// Scene 对象引用:由 <see cref="ExposedReferenceTestClip"/> 通过 ExposedReference.Resolve 解析后赋值。
/// </summary>
public Transform targetTransform;
/// <summary>
/// 位置偏移(世界坐标)。
/// </summary>
public Vector3 positionOffset;
/// <summary>
/// 进入该片段时记录的“原始世界坐标”。
/// </summary>
private Vector3 _enterWorldPosition;
/// <summary>
/// 是否已经捕获过进入时的原始坐标(保证只记录一次)。
/// </summary>
private bool _hasCapturedEnterWorldPosition;
/// <summary>
/// 缓存轨道 Binding 注入的对象(也就是被驱动的 GameObject),用于在退出回调中恢复坐标。
/// </summary>
private GameObject _boundGameObject;
/// <summary>
/// 上一次回调时的权重(用于区分“离开片段”与“片段内暂停”)。
/// </summary>
private float _lastFrameWeight;
/// <summary>
/// Timeline 每帧评估回调:根据 Binding + ExposedReference 结果驱动目标物体。
/// </summary>
/// <param name="playable">当前片段的 Playable。</param>
/// <param name="info">帧信息。</param>
/// <param name="playerData">轨道 Binding 注入对象(本轨道为 GameObject)。</param>
public override void ProcessFrame(Playable playable, FrameData info, object playerData)
{
_lastFrameWeight = info.weight;
GameObject boundGameObject = playerData as GameObject;
if (boundGameObject == null || targetTransform == null)
return;
// 缓存绑定对象:退出片段时要用它恢复坐标。
_boundGameObject = boundGameObject;
// 第一次真正影响输出时(weight > 0),记录进入片段前的原始坐标。
// 说明:只有 ProcessFrame 能拿到 playerData,所以“捕获原始坐标”放这里最稳。
if (!_hasCapturedEnterWorldPosition && info.weight > 0f)
{
_hasCapturedEnterWorldPosition = true;
_enterWorldPosition = boundGameObject.transform.position;
}
boundGameObject.transform.position = targetTransform.position + positionOffset;
}
/// <summary>
/// 片段退出回调:恢复进入片段前的原始坐标。
/// </summary>
public override void OnBehaviourPause(Playable playable, FrameData info)
{
_lastFrameWeight = info.weight;
// 只在“离开片段”(权重归 0)时恢复。
// 如果只是暂停且仍在片段范围内(权重仍 > 0),保持当前位置,保证编辑器所见即所得。
if (info.weight <= 0f)
RestoreEnterWorldPositionIfNeeded();
}
/// <summary>
/// Graph 停止回调:兜底恢复进入片段前的原始坐标。
/// </summary>
public override void OnGraphStop(Playable playable)
{
// Graph 停止时也遵循“只在不在片段范围内才恢复”的规则:
// - 如果最后一次权重仍 > 0(一般表示停在片段内部),保持当前位置。
// - 否则恢复进入片段前的位置。
if (_lastFrameWeight <= 0f)
RestoreEnterWorldPositionIfNeeded();
}
/// <summary>
/// 若已记录进入坐标,则恢复并清理缓存状态。
/// </summary>
private void RestoreEnterWorldPositionIfNeeded()
{
if (!_hasCapturedEnterWorldPosition)
return;
if (_boundGameObject != null)
_boundGameObject.transform.position = _enterWorldPosition;
_hasCapturedEnterWorldPosition = false;
_boundGameObject = null;
}
}
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com