3.PlayableDirector

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,换主角/换模型都靠它)。

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/GetGenericBindingSetReferenceValue/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 就是“这条轨道要用的绑定对象”

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:稳定,不依赖轨道名字符串

  • **先 GetGenericBindingSetGenericBinding**:更容易做增量更新

    • 避免重复 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 对象

Timeline 里常见的处理方式有两类:

  • **ExposedReference<T>**:剧本里只存一个“占位”,运行时由 PlayableDirector(实现了 IExposedPropertyTable)解析成真实对象。
  • 走轨道绑定传递:把对象放在轨道绑定(GenericBinding)里,让 Clip 在运行时通过 binding/playerData 拿到它,避免 Clip 直接持有场景引用。

ExposedReference源码解析

ExposedReference<T> 的定位很明确:PlayableAsset 侧不直接保存 Scene 引用,而是保存一个可序列化的 key;运行时再借助 PlayableDirectorIExposedPropertyTable)把 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() 的逻辑就是一次“查表 + 回退”:

  • exposedNameresolver.GetReferenceValue(),通过 idValid 判断该 key 是否有有效绑定。
  • 绑定存在则返回绑定对象(as T)。
  • 否则返回 defaultValue(同样按 T 转换)。

ExposedReference使用实战自定义轨道

  • 目标

    • 在 Clip 的 Inspector 里选一个 Scene 里的 Transform 作为目标点,Timeline 播放时把轨道绑定的 GameObject 移动到 targetTransform.position + positionOffset;离开片段时把物体的位置还原。
  • 场景准备

    • 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

×

喜欢就点赞,疼爱就打赏