2.Timeline轨道

  1. 2.Timeline轨道
    1. 2.1 知识点
      1. 创建轨道
      2. 轨道类型
      3. Activation Track 轨道
      4. Animation Track 轨道
        1. 播放现成的 AnimationClip
        2. 录制(关键帧)
        3. Inspector参数
      5. Audio Track 轨道
        1. 绑定 AudioSource vs 不绑定
        2. Inspector参数
      6. Control Track 轨道
        1. 控制粒子特效
        2. 嵌套 Timeline
        3. Inspector参数
      7. Signal Track 轨道
        1. Signal 工作流:Emitter / SignalAsset / Receiver / Reaction
        2. 准备SignalReceiver 接收端回调脚本
        3. 创建 Signal Track,并绑定接收对象
        4. 创建 Signal 资源,并在轨道上放一个 Signal Emitter
        5. 配置 Reaction(要调用哪个函数)
        6. 运行效果
        7. Inspector参数
        8. 运行时替换 Receiver:Binding 只解决“发给谁”,Reaction 还要解决“做什么”
        9. 运行时代码补齐 Reaction(AddReaction)
      8. Playable Track 轨道
        1. 自定义 Playable:PlayableAsset + PlayableBehaviour
        2. 把 PlayableAsset 放到轨道上
        3. 运行时动态给 PlayableAsset 填参数(Binder)
        4. Inspector参数
      9. 自定义轨道
        1. Timeline里不同的角色
        2. 自定义轨道通用思路
        3. 区别:普通(无 Mixer) vs Mixer(有 Mixer)
        4. CreateTrackMixer 和 ClipCaps.Blending 的关系
        5. 自定义简单轨道
        6. 自定义混合轨道
    2. 2.2 知识点代码
      1. ChangeObjPosPlayableAsset.cs
      2. ChangeObjPosPlayableBehaviour.cs
      3. ChangeObjPosPlayableAssetBinder.cs
      4. SignalReceiverTest.cs
      5. VayneControlSimpleTrack.cs
      6. VayneControlSimpleClip.cs
      7. VayneControlSimpleBehaviour.cs
      8. VayneControlMixerTrack.cs
      9. VayneControlMixerClip.cs
      10. VayneControlMixerBehaviour.cs
      11. VayneControlMixerTrackMixerBehaviour.cs

2.Timeline轨道


2.1 知识点

创建轨道


Timeline 左侧轨道区右键(或点左上角 +)会弹出添加菜单,常用轨道基本都在这里。

轨道类型

每条 Track 本质上都是“某类内容的时间容器”,左侧一行,右侧一条时间带

轨道 绑定对象/组件 主要用途 备注
Track Group - 纯分组,类似文件夹 不参与播放,只是整理轨道
Activation Track GameObject 控制对象的激活/禁用 轨道上放的是 Activation Clip,不是关键帧
Animation Track Animator 播动画、做混合、可录制 既可以拖 AnimationClip,也可以录制关键帧
Audio Track AudioSource 播音频、裁剪/淡入淡出 最常见就是卡点放音效
Control Track GameObject 控 prefab/粒子/子 Director 常用来做“嵌套播放”(ControlPlayableAsset)
Playable Track PlayableDirector 播一个 PlayableAsset(含子 Timeline) 走 Playable 系统的“通用入口”
Signal Track Signal Receiver 打时间点事件(发信号) 放的是 Signal Marker(时间点),不是关键帧曲线

Activation Track 轨道

Activation Track 用来控制 GameObject 的激活/禁用(本质是 SetActive(true/false) )。

  • 轨道左侧的绑定槽拖入目标对象(示例:Sphere
  • 在右侧时间轴上创建/拉伸 Active 片段:片段覆盖的时间范围内对象为激活态,其余时间为非激活态


Animation Track 轨道

Animation Track 绑定的是 Animator,用来播动画(拖 AnimationClip)或者直接录关键帧。

播放现成的 AnimationClip

  • 先把角色(带 Animator)拖到轨道左侧的绑定槽(示例里显示 Vayne (Animator)
  • 往右侧时间轴里放动画:直接拖 AnimationClip 进来,或者点 Clip 右侧的小圆点,从列表里选

录制(关键帧)

  • 把要录的对象拖到绑定槽。如果对象没有 Animator,会弹出提示(例如 Create Animator on Cube),点一下就会自动补一个 Animator

  • 点轨道行上的红色录制按钮进入录制状态(时间轴会出现 Recording...
  • 这时在 Scene 里改 Transform(位置/旋转/缩放),Timeline 会在当前时间点打关键帧

  • 需要精修曲线:轨道右键 Edit in Animation Window,切到 Animation 窗口看关键帧/曲线

Inspector参数

Animation Clip 片段参数

  • Clip Timing

    • Start / End / Duration:片段在 Timeline 上的起止时间和时长(单位秒/帧)。改 Start 相当于“整段往左/往右挪”;改 Duration 相当于“拉长/缩短这段在时间轴上的占位”。
    • Clip In:从源 AnimationClip 的第几秒开始截取播放(等于跳过开头一段)。
      • 例:Clip In = 0.5s,表示源动画从 0.5 秒处开始播。
    • Speed Multiplier:播放速度倍率。
      • 例:2 表示两倍速;同样的时间轴时长里会走过更多动画帧。
  • Ease In / Ease Out

    • 片段进出时的“淡入淡出时间”(影响权重曲线)。片段重叠混合时最直观:淡入长一点就更柔和,淡入为 0 就是硬切。
  • Animation Extrapolation(Pre/Post Extrapolate)

    • Pre-Extrapolate:播放头在片段开始之前(左边那段空白)时,这个片段要不要“补姿势”,怎么补。
    • Post-Extrapolate:播放头在片段结束之后(右边那段空白)时,这个片段要不要“补姿势”,怎么补。
    • 两边下拉选项是一套,区别只在“作用在开始前 / 结束后”。
    • None:片段外不管(等于不给这段姿势兜底,外面是什么就是什么)。
    • Hold:定格。
      • Pre=定格第一帧;Post=定格最后一帧。
    • Loop:循环补(片段外继续按循环播放)。
    • Ping Pong:往返补(到头就反向播,来回弹)。
    • Continue:继续补(按当前速度把时间往外推,像“片段还在往后走”)。
    • 例:过场最后想“停住摆 pose”,常用 Post=Hold;想让角色一直跑动不停,就用 Post=Loop。
  • Blend Curves(In/Out)

    • 片段淡入/淡出的权重曲线(就是 Timeline 里那条绿线怎么爬升/下降)。
    • Auto:用默认曲线(大部分时候够用)。
    • Manual:手动曲线(需要“更硬切 / 更慢进出场”时再用)。
  • Animation Playable Asset

    • Animation Clip:当前片段用的源动画。
    • Foot IK:勾上会启用脚部 IK(更贴地,但也更吃资源/可能影响风格)。
    • Loop:循环策略(例如 Use Source Asset 表示跟随源 AnimationClip 的循环设置)。

Animation Track 轨道参数

  • Track Offsets
    • 控制“动画偏移怎么应用”:简单理解就是 用角色当前 Transform 当基准,还是 用录制/偏移参数当基准
    • Position / Rotation:给整条轨道一个偏移(所有片段一起吃)。
  • Recorded Offsets
    • 录制关键帧时产生的偏移记录(常见:录制时物体起始位置不在原点,就会出现一组 Recorded Offsets)。
  • Apply Foot IK
    • 轨道级别的脚 IK 开关(对这条轨道的片段生效)。
  • Apply Avatar Mask / Avatar Mask
    • 给这条动画轨道套一个 AvatarMask,只影响指定骨骼(比如只做上半身)。
    • 例:上半身开枪/下半身跑步,通常就是靠轨道的 Mask 做分层。
  • Default Offsets Match Fields(Position/Rotation 的 X/Y/Z)
    • 理解成“对齐时要不要管某些轴”:勾了就参与匹配/对齐,不勾就当它不存在。
    • 例:只想对齐水平位置,不想动高度,那就把 Position 的 Y 关掉。

Audio Track 轨道

Audio Track 绑定的是 AudioSource,时间轴里放的是音频片段(Audio Clip)。

放音频有两种方式:

  • 直接把 AudioClip 拖进时间轴
  • 或者在轨道上右键:Add From Audio Clip

常见编辑操作:

  • 裁剪/分割:右键片段 Editing -> Trim Start / Trim End / Split
  • 淡入淡出/音量:选中片段后,在 Inspector 里调 Ease In/OutVolume(以及 Clip Timing)

绑定 AudioSource vs 不绑定

  • 绑定 AudioSource:声音从指定的 AudioSource 出。
    好处是能吃到 AudioSource 的配置(比如 2D/3D、空间衰减、AudioMixerGroup 路由等),适合做“场景里有位置”的音效(脚步、爆炸、角色语音)。
  • 不绑定 AudioSource(显示 None (Audio Source):Timeline 播放时会用默认的输出去播(通常会走一个内部/临时的 AudioSource)。
    省事,但基本当成“全局 2D 声音”用,想做 3D/混音路由就不太好控。

Inspector参数

Audio Clip 片段参数

  • Clip Timing(Start / End / Duration / Clip In / Speed Multiplier)
    • 含义和 Animation Clip 一样:控制这段音频在时间轴上的位置、时长、从音频第几秒开始播、以及播放速度。
  • Ease In / Ease Out
    • 音频淡入淡出时间(最常用)。
    • 例:Ease In = 0.1s 可以把“啪”一下的入场变成更自然的渐入。
  • Blend Curves(In/Out)
    • 淡入淡出的权重曲线。想做更快的入场/更慢的退场,就在这里调曲线。
  • Audio Playable Asset
    • Clip:音频资源。
    • Loop:是否循环(BGM 常用;音效一般不勾)。
    • Volume:片段音量(乘法)。
      • 例:0.5 就是半音量;同一条轨道里不同片段可以用这个快速做强弱。

Control Track 轨道

Control Track 一般用来“控制一个 GameObject 的生命周期/播放”,典型两种:控粒子嵌套子 Timeline

控制粒子特效

  • 场景里先准备一个 Particle System
  • 把粒子对象拖到 Control Track(或者轨道右键 Add From Game Object 选对应对象)
  • 时间轴上出现一个控制片段,片段覆盖的时间里粒子会播放

注意:

  • 时间轴支持倒放预览(拖播放头反向走就能看到)
  • 片段同样可以裁剪/分割,控制粒子的播放区间

嵌套 Timeline

可以把子 Timeline 当成“可复用的小片段”:一个小过场/一个技能段落,做成子 Timeline,主 Timeline 里拼装。

先创建一个 SubTimeline(例子里是录一个 Cube 的缩放曲线):

然后回到主 Timeline:创建 Control Track,把 SubTimeline 资源拖到轨道里,就完成嵌套。

双击子 Timeline 的 Clip 可以直接跳进子 Timeline 编辑(顶部会有面包屑,能切回主 Timeline)。

Inspector参数

  • Clip Timing(Start / End / Duration / Clip In / Speed Multiplier)
    • 控制这个控制片段在时间轴上的起止时间和速度(速度主要影响一些可时间驱动的控制对象)。
  • Control Playable Asset
    • Source Game Object:控制场景里已有的对象(图里是 SubTimeline)。
      • 例:SubTimeline 这个对象上挂了 PlayableDirector,勾上 Control Playable Directors 后,这段时间就会驱动它播放。
    • Prefab:控制一个 Prefab(播放时临时实例化)。
      • 例:爆炸/一次性特效更适合放 Prefab,这样不用场景里常驻。
    • Control Activation:是否由 Timeline 控制对象激活/禁用(大致等价于这段时间 SetActive(true))。
    • Post Playback
      • Active:离开片段后保持激活(这段播完对象还留在场景里)。
      • Inactive:离开片段后置为非激活(播完就关掉/隐藏)。
      • Revert:离开片段后恢复进入片段前的激活状态(最省心,避免把场景留脏)。
  • Advanced
    • Control Playable Directors:接管目标对象(及其子物体)上的 PlayableDirector
    • Control Particle Systems:接管粒子系统(播放/停止/模拟)。
    • Random Seed:粒子随机种子;固定后预览更稳定。
    • Control ITimeControl:接管实现了 ITimeControl 的组件(更偏自定义时间驱动接口)。
    • Control Children:把控制扩散到子物体。

Signal Track 轨道

Signal Track 用来做“时间点事件”:在某一帧/某一时刻发出一个 Signal,然后让某个对象上的 SignalReceiver 调用回调。

Signal 工作流:Emitter / SignalAsset / Receiver / Reaction

Signal 这套东西看起来分散在 Timeline 和 Inspector 里,实际就是一条很固定的链路:

  • SignalAsset(发什么)
    • Project 里的一个资源(Create -> Signal 创建)。
    • 选中时间轴上的 Signal Emitter(Marker) 后,在 Inspector 里能看到 Emit Signal 字段;这个下拉框选的就是 SignalAsset:决定“发出的信号是哪一个资产”。
  • Signal Emitter(什么时候发)
    • 时间轴上的一个 Marker(一个小标记点)。
    • 它持有 SignalAsset 的引用,并在时间线评估到该时刻时触发一次“发信号”。
  • SignalReceiver(发给谁)
    • Signal Track 左侧的 Binding 槽绑定的对象上,需要有 SignalReceiver 组件。
    • Timeline 只负责把信号送到这个 Receiver,至于“收到以后做什么”,不在 Track 里存。
  • Reaction(收到后做什么)
    • Reaction 配置是存放在 SignalReceiver 组件里的:本质是一张表 SignalAsset -> UnityEvent
    • 时间到时,Receiver 根据 SignalAsset 找到对应的 UnityEvent,然后 Invoke。



上面两张图对应的就是这条链路在编辑器里的落点:

  • Project 里先创建了一个 SignalEmitterTest(它就是 SignalAsset)。
  • 在时间轴上选中 Signal Emitter,Inspector 的 Emit Signal 下拉选择 SignalEmitterTest:意思是“到点就发这个信号”。
  • Signal Track 左侧绑定了一个 SignalReceiver 对象:意思是“信号发给谁”。
  • SignalReceiver 组件里配置了 SignalEmitterTest -> SignalReceiverTest.OnReceiveSignal():这一步才是“收到后做什么”(UnityEvent)。

换个更生活化的说法,这套链路可以当成“一次消息投递”:

  • SignalAsset:消息的“主题/类型”(例如“命中事件”“脚步声事件”)。
  • Emitter:按时间点发送这条消息的“发件人”(到点就发一次)。
  • Receiver:接收消息的“收件箱/邮箱”(消息发给谁)。
  • Reaction:收到某个主题的消息后要执行的“处理流程”(对应 UnityEvent 里选的函数)。

后面的就以 SignalEmitterTest 这条链路为例,把创建、绑定、触发、以及替换 Receiver 的细节走一遍。

准备SignalReceiver 接收端回调脚本

先在 Hierarchy 里建一个空物体,命名 SignalReceiver,再挂一个脚本用来接收信号([RequireComponent] 会自动补上 SignalReceiver 组件):

using UnityEngine;
using UnityEngine.Timeline;

[RequireComponent(typeof(SignalReceiver))]
public class SignalReceiverTest : MonoBehaviour
{
    // 被 SignalReceiver 调用的回调(public 即可)
    public void OnReceiveSignal()
    {
        Debug.Log("OnReceiveSignal: 接收到信号!");
    }
}

创建 Signal Track,并绑定接收对象

在 Timeline 里添加 Signal Track,把 SignalReceiver 物体拖到轨道左侧的绑定槽(显示 SignalReceiver (Signal Receiver))。

创建 Signal 资源,并在轨道上放一个 Signal Emitter

Signal 本身是一个资源:Project 视图右键 Create -> Signal 创建(例如 SignalEmitterTest),再把它拖到 Signal Track 的时间轴上,就得到一个 Signal Emitter(一个小标记点)。

配置 Reaction(要调用哪个函数)

选中时间轴上的 Signal Emitter,在 Inspector 里点 Add Reaction,然后从 No Function 下拉里选回调(示例:SignalReceiverTest.OnReceiveSignal())。

运行效果

播放 Timeline,时间线走到 Signal Emitter 的位置时,会在 Console 里看到日志输出。

Inspector参数

Signal Emitter 参数

  • Time
    • 这个 Signal 触发的时间点(秒/帧)。本质就是一个“时间点 Marker”。
  • Retroactive
    • 是否允许“补触发”:播放头如果是跳过去的(比如直接拖动时间轴、或代码 SetTime()),也要不要触发这个 Signal。
    • 例:做技能编辑器时经常会拖动时间轴预览,此时想让事件补触发就勾上;只想在正常播放时触发就不勾。
  • Emit Once
    • 勾上即“只触发一次”,针对的是循环播放:Timeline 设成 Loop 时,这个 Signal 整段只打一次。拖动时间轴、Seek 过去要不要补触发,看 Retroactive,和 Emit Once 无关。
  • Emit Signal
    • 选择要发出的 Signal 资源(图里是 SignalEmitterTest)。

Signal Receiver / Reaction

  • Receiver Component on
    • 事件会发给哪个对象上的 SignalReceiver(图里是 SignalReceiver)。
  • Reaction
    • Signal -> 要调用的函数。这里选的是 SignalReceiverTest.OnReceiveSignal()
    • 右侧的 Runtime Only 表示只在运行时触发(编辑器预览下是否触发取决于 Timeline 的播放方式)。

运行时替换 Receiver:Binding 只解决“发给谁”,Reaction 还要解决“做什么”

常见踩坑点是:把 Signal Track 的绑定对象从 SignalReceiver 换成 SignalReceiver2 后,Emitter 还在,但回调不触发

原因并不复杂:Reaction 是存放在 SignalReceiver 组件上的数据

  • 绑定换过去以后,Signal 当然会“送到”新的 Receiver。
  • 但如果 SignalReceiver2 上的 SignalReceiver 组件里,没有配置同一个 SignalAsset 对应的 Reaction,那么 Receiver 收到信号也不知道要调用哪个函数。

稳定做法一般就两种:

  • 编辑器配好:在 SignalReceiver2SignalReceiver 组件里,也为同一个 SignalAsset 配一条 Reaction,指向 SignalReceiverTest.OnReceiveSignal()
  • 运行时代码补齐:在初始化/换绑时,用代码把 SignalAsset -> UnityEvent 的映射补到当前 Receiver 上(动态生成角色、动态换接收者时更省事)。

运行时代码补齐 Reaction(AddReaction)

下面这段代码是确保某个 SignalReceiver 上存在指定 SignalAsset 的 Reaction,并把回调函数挂进去。

using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Timeline;

/// <summary>
/// 运行时为 SignalReceiver 补齐 Reaction:
/// - 确保 receiver 对 signalAsset 有一条 Reaction
/// - 把目标回调注册到 UnityEvent
/// </summary>
[RequireComponent(typeof(SignalReceiver))]
public sealed class SignalReceiverReactionInstaller : MonoBehaviour
{
    [Header("要匹配的 Signal(对应 Emitter 的 Emit Signal)")]
    [SerializeField] private SignalAsset signalAsset;

    [Header("要执行的回调(示例脚本)")]
    [SerializeField] private SignalReceiverTest signalReceiverTest;

    private void Awake()
    {
        SignalReceiver signalReceiverComponent = GetComponent<SignalReceiver>();
        if (signalAsset == null || signalReceiverTest == null)
            return;

        // SignalReceiver 内部维护的是:SignalAsset -> UnityEvent
        UnityEvent reactionEvent = signalReceiverComponent.GetReaction(signalAsset);

        // GetReaction 返回空:表示还没配置过这条 Signal
        if (reactionEvent == null)
        {
            reactionEvent = new UnityEvent();
            signalReceiverComponent.AddReaction(signalAsset, reactionEvent);
        }

        // 这里按需注册回调(避免重复 AddListener)
        reactionEvent.RemoveListener(signalReceiverTest.OnReceiveSignal);
        reactionEvent.AddListener(signalReceiverTest.OnReceiveSignal);
    }
}

Playable Track 轨道

Playable Track 用来挂自定义 Playable,本质就是把 Playable API 的塞回 Timeline 里。

一般要继承两个类:

  • 一个 PlayableAsset:负责创建 Playable(CreatePlayable
  • 一个 PlayableBehaviour:负责每帧干活(ProcessFrame / OnBehaviourPlay / OnBehaviourPause

下面实现给一个薇恩一个起始坐标,然后每帧让它“往外飘一点”。

自定义 Playable:PlayableAsset + PlayableBehaviour

using UnityEngine;
using UnityEngine.Playables;

public class ChangeObjPosPlayableBehaviour : PlayableBehaviour
{
    public GameObject GameObj;
    public Vector3 Position;
    public float Time;

    public override void OnGraphStart(Playable playable)
    {
        base.OnGraphStart(playable);
        Debug.Log("OnGraphStart=======================");
    }

    public override void OnGraphStop(Playable playable)
    {
        base.OnGraphStop(playable);
        Debug.Log("OnGraphStop=======================");
    }

    public override void OnBehaviourPlay(Playable playable, FrameData info)
    {
        base.OnBehaviourPlay(playable, info);
        Debug.Log("OnBehaviourPlay=======================");

        if (null != GameObj)
        {
            // 这里的逻辑是改变物体的坐标,具体逻辑就看具体需求
            GameObj.transform.position = Position;
        }
    }

    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        base.ProcessFrame(playable, info, playerData);
        Debug.Log("ProcessFrame======================= Position: " + Position);
        Time += info.deltaTime;
        if (null != GameObj)
        {
            // 这里的逻辑是改变物体的坐标,具体逻辑就看具体需求
            GameObj.transform.position = Position + new Vector3(Time, info.deltaTime, Time);
        }
    }

    public override void OnBehaviourPause(Playable playable, FrameData info)
    {
        base.OnBehaviourPause(playable, info);
        Debug.Log("OnBehaviourPause=======================");

        if (null != GameObj)
        {
            GameObj.transform.position = Vector3.zero;
        }
    }
}
using UnityEngine;
using UnityEngine.Playables;

public class ChangeObjPosPlayableAsset : PlayableAsset
{
    public GameObject GameObj;
    public Vector3 Position;

    public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
    {
        ChangeObjPosPlayableBehaviour changeObjPosPlayableBehaviour = new ChangeObjPosPlayableBehaviour();
        changeObjPosPlayableBehaviour.GameObj = GameObj;
        changeObjPosPlayableBehaviour.Position = Position;
        return ScriptPlayable<ChangeObjPosPlayableBehaviour>.Create(graph, changeObjPosPlayableBehaviour);
    }
}

把 PlayableAsset 放到轨道上

ChangeObjPosPlayableAsset 脚本(.cs)直接拖到 Playable Track 的时间轴上,会生成一个对应的 Clip:

选中这个 Clip,在 Inspector 里能看到 GameObj / Position 这两个参数(如果直接在这里填,也能跑)。

运行时动态给 PlayableAsset 填参数(Binder)

Timeline 资源属于“可复用资产”,很多时候不适合把场景对象直接写死在资产里,所以一般会在运行时做一次绑定。

这里用一个简单粗暴的方式:通过轨道名找到目标轨道,再拿到轨道上的 Clip 资产,最后把 GameObj/Position 填进去。

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

public class ChangeObjPosPlayableAssetBinder : MonoBehaviour
{
    public PlayableDirector playableDirector;
    public GameObject vayne;
    public Vector3 position;
    private ChangeObjPosPlayableAsset _changeObjPosPlayableAsset;

    void Start()
    {
        PlayableAsset timelinePlayableAsset = playableDirector.playableAsset;
        foreach (var binding in timelinePlayableAsset.outputs)
        {
            // 轨道名
            var trackName = binding.streamName;
            if ("MyPlayableTest" == trackName)
            {
                // 轨道
                TrackAsset trackAsset = binding.sourceObject as TrackAsset;
                if (trackAsset == null) continue;

                // 轨道上的片段,每个片段都是PlayableAsset的子类
                IEnumerable<TimelineClip> timelineClipList = trackAsset.GetClips();
                foreach (var timelineClip in timelineClipList)
                {
                    if (timelineClip.asset is not ChangeObjPosPlayableAsset asset) continue;

                    _changeObjPosPlayableAsset = asset;

                    // 动态设置go对象的坐标
                    if (_changeObjPosPlayableAsset != null)
                    {
                        _changeObjPosPlayableAsset.GameObj = vayne;
                        _changeObjPosPlayableAsset.Position = position;
                    }

                    break;
                }
            }
        }
    }
}

轨道名改成 MyPlayableTest,Binder 通过这个名字定位目标轨道:

ChangeObjPosPlayableAssetBinder 挂到一个对象上,填好 PlayableDirector / vayne / position

运行效果:对象会在播放区间内被脚本逐帧更新位置,Console 里也能看到 ProcessFrame 的输出。

Inspector参数

  • Clip Timing / Blend Curves
    • Clip Timing:这段自定义片段在时间轴上的起止/时长。
    • Blend Curves(In/Out):这段片段的权重曲线(做叠加/重叠时会用到)。如果只是单独一段跑逻辑,通常保持默认即可。
  • Change Obj Pos Playable Asset(自定义字段)
    • Game Obj:要控制的目标对象(例子里填的是 Vayne)。
    • Position:起始位置(示例里是 x/y/z)。
    • 例:把 Position 设成 (0, 0, 0),片段开始时对象会先被放到原点,然后在 ProcessFrame 里按脚本逻辑每帧更新位置。

自定义轨道

Timeline里不同的角色

Timeline 的自定义轨道,本质上就是把一套 Playable API “包装成可在 Timeline 面板里拖拽编辑的轨道/片段”。

Timeline资产 里看到的“剧本/配置”,到了运行时会被导演拿去“排戏”,变成一堆 Playable 在图里跑。

可以用这样的比喻:

  • TimelineAsset = 剧本:写死在资源里的配置(哪个时间点发生什么),本身不“动”。
  • PlayableDirector = 导演:读剧本的人。点 Play/拖时间轴,本质就是导演在推进时间、发号施令。
  • PlayableGraph = 舞台/排练现场:导演把剧本拆开后搭起来的运行时图。
  • Playable = 戏(正在演):运行时真正执行的“节点”。
    重点:Playable 里拿到的 Behaviour 通常是 clone或者说是实例化后的,也就是“把剧本里的配置抄一份,拿去现场用”,避免污染原始剧本资源。

自定义轨道通用思路

自定义轨道一般会拆成 三个类(需要处理同轨道混合时,通常会变成 四个类)。

  • Track(继承 TrackAsset)= 分镜轨道
    负责“这条轨道允许放什么 Clip、要绑哪个演员(Binding)”,本身不写具体动作。

    • 用特性把“轨道颜色/能放什么 Clip/需要绑定什么对象”进行声明
      • TrackColor(float r, float g, float b):轨道颜色(Timeline 面板里用来区分轨道类型)
      • TrackClipType(Type clipClass):该轨道允许创建/放置的 PlayableAsset 类型(一个轨道可以声明多个)
      • TrackBindingType(Type type):轨道绑定对象类型(例如绑定 Animator;若该轨道不需要绑定对象,可以不写)
    • 常用函数:
      • OnCreateClip(TimelineClip clip):新建片段时给默认值/默认显示名
      • CreateTrackMixer(...):需要“同轨道统一混合/统一执行”时才重写
  • Clip/Asset(继承 PlayableAsset,通常实现 ITimelineClipAsset)= 片段
    时间轴上那一段片段就是它:起止时间、淡入淡出、以及该片段的参数(每个片段一份配置)。

    • 一般做法:在 Inspector 里声明要配的字段,CreatePlayable() 里把字段拷贝到 Behaviour;真正的驱动逻辑放 ProcessFrame()(或交给 Mixer)。

    • 如果要允许片段重叠:clipCaps 里加 ClipCaps.Blending

    • 关于引用

      • 现象:Inspector 里拖场景对象会报 Type mismatch
      • 原因Clip 是资产(Project 里的静态数据),本质就是“配置/剧本”。
        值类型(float / Vector3)直接在 Inspector 填就行;但 GameObject / Transform 属于“运行时对象引用”,资产不应该反向指向场景对象。
        反过来,拖 Project 里的 Prefab 没问题(配置引用配置)。
      • 做法:需要引用场景对象时,让“运行时”来绑定(剧本不记演员,导演上场再分配)。
        对 Timeline 来说,运行时入口就是 PlayableDirector:用 ExposedReference<T>,播放时 Resolve() 出真正对象,再塞进 Behaviour。
  • Behaviour(继承 PlayableBehaviour)= 片段的行为说明

    • 常见写法是:PlayableAsset.CreatePlayable() 里把 Inspector 字段拷贝进 Behaviour;再在 ProcessFrame() 里每帧驱动 Animator/Transform(相当于“按剧本执行动作”)。
    • 轻量做法:逻辑全写在 Behaviour 的 ProcessFrame(),靠 playerData 拿到轨道绑定对象。Behaviour 的 ProcessFrame() 只会在该 Clip 生效区间内被调用。
    • 如果有 Mixer:Behaviour 里只留字段,ProcessFrame() 交给 TrackMixerBehaviour 统一处理
  • TrackMixerBehaviour(可选,继承 PlayableBehaviour)= 轨道的统一调度(Mixer)


    • 只有当片段会重叠、需要“怎么混/谁说了算”时才需要它。
    • 没有 Mixer:每个 Clip 各演各的,重叠就容易“抢控制”。
    • 有 Mixer:同轨道的输入统一进 Mixer,由 Mixer 决定这一帧到底输出什么(位置按权重混、动画用 Layer 混、或只取主导片段等)。
    • Timeline 会把同轨道上所有 Clip 作为输入喂给 Mixer,在 Mixer 里统一决定“怎么混/谁主导/输出什么”。
    • Mixer 的 ProcessFrame() 是每帧都会跑:遍历所有输入 Clip,拿到这一帧的权重,再算最终输出。

区别:普通(无 Mixer) vs Mixer(有 Mixer)

  • 普通(无 Mixer)
    Timeline 每个 Clip 自己跑自己的 PlayableBehaviour.ProcessFrame()
    重叠时:两个 Clip 会同时执行,谁最后写 Animator/Transform 就覆盖谁,行为不可控(“抢控制”)。

  • Mixer(有 Mixer)
    Track 重写 CreateTrackMixer(),Timeline 会把同轨道上所有 Clip 的输出汇总成一个 Mixer来处理。
    重叠时:你可以在 Mixer 里统一决定怎么混(按权重混位置、用 Layer 混动画、或只取主导片段等),行为可控、可解释。

CreateTrackMixerClipCaps.Blending 的关系

这俩通常是配合使用的:

  • **CreateTrackMixer()**:提供“怎么混合/怎么执行”的地方(代码层面的混合策略)。
  • ClipCaps.Blending:告诉 Timeline 编辑器/系统“这个 Clip 类型允许重叠混合”。
    没开 Blending 时,同轨道片段通常
    不允许重叠
    ,你拖重叠会被“弹回去”,就算你写了 Mixer 也用不上重叠混合。

所以一般流程就是:

  • 想支持重叠混合 ⇒ clipCaps 包含 ClipCaps.Blending
  • 想自定义重叠时的行为 ⇒ Track 实现 CreateTrackMixer()(并在 Mixer 里按权重处理)

下面用一套对照例子说明:同样是“驱动薇恩的动画 + 位置”,分别实现 普通版(无 Mixer)Mixer 版(有 Mixer)

自定义简单轨道

目标:每个 Clip 都能配置 stateName / targetPosition,用于驱动 Vayne 的动画和位置;拖动时间轴能直接看到结果(所见即所得)。

  • VayneControlSimpleTrack.cs:不做混合的自定义轨道,VayneControlSimpleTrack 不重写 CreateTrackMixer(),也就意味着:Timeline 不会给它一个统一的 Mixer
using UnityEngine;
using UnityEngine.Timeline;

/// <summary>
/// 无 Mixer 版:自定义轨道(不重写 CreateTrackMixer)。
/// - 轨道绑定 Animator(把薇恩的 Animator 拖到轨道 Binding 上)
/// - 轨道里放若干 VayneControlSimpleClip
/// 
/// 对比点:片段重叠时,各自 Behaviour 会同时执行,控制会互相覆盖。
/// </summary>
[TrackColor(0.20f, 0.70f, 0.20f)]
[TrackClipType(typeof(VayneControlSimpleClip))]
[TrackBindingType(typeof(Animator))]
public class VayneControlSimpleTrack : TrackAsset
{
    /// <summary>
    /// 创建新片段时回调:设置一个易读的默认显示名(只影响 Timeline 面板显示)。
    /// </summary>
    /// <param name="clip">新建的 TimelineClip。</param>
    protected override void OnCreateClip(TimelineClip clip)
    {
        // 创建新片段时设置一个易读的默认名字
        base.OnCreateClip(clip);

        if (string.IsNullOrWhiteSpace(clip.displayName))
            clip.displayName = "Vayne (Simple)";
    }
}
  • VayneControlSimpleClip.cs:片段资源,暴露 Inspector 字段,并把字段传给 VayneControlSimpleBehaviour
    由于是无 Mixer 方案,片段重叠会“抢控制”,所以这里 clipCaps => ClipCaps.None,直接禁止同轨道重叠。
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

/// <summary>
/// 无 Mixer 版:Clip 资源(Inspector 上每个片段一份配置)。
/// CreatePlayable 时把 Inspector 字段拷贝到 Behaviour,Behaviour 在每帧执行逻辑。
/// </summary>
public class VayneControlSimpleClip : PlayableAsset, ITimelineClipAsset
{
    /// <summary>
    /// 要播放/采样的 Animator State 名(必须与 Animator Controller 里的 State 名一致)。
    /// </summary>
    [Tooltip("要播放/采样的 Animator State 名(必须与 Animator Controller 里的 State 名一致)")]
    public string stateName = "Idle";

    /// <summary>
    /// 是否循环播放动画:
    /// 勾选=按动画原始时长循环;不勾选=只播一次并停在最后一帧。
    /// </summary>
    [Tooltip("是否循环播放动画:勾选=循环;不勾选=播一次停在最后一帧")]
    public bool loopAnimation = false;

    /// <summary>
    /// 角色要被设置到的目标位置(世界坐标)。
    /// </summary>
    [Tooltip("角色要被设置到的目标位置(世界坐标)")]
    public Vector3 targetPosition;

    /// <summary>
    /// 片段退出时:是否恢复到进入片段前的位置。
    /// </summary>
    [Header("Exit")]
    [Tooltip("片段退出时:是否恢复到进入片段前的位置")]
    public bool restorePositionOnExit = true;

    /// <summary>
    /// 片段退出时:是否恢复到进入片段前的动画状态(Animator 当时正在播放的状态)。
    /// </summary>
    [Tooltip("片段退出时:是否恢复到进入片段前的动画状态(Animator 当时正在播放的状态)")]
    public bool restoreAnimationOnExit = true;

    /// <summary>
    /// 该片段支持的能力;这里保持最简(不声明 Loop/Speed 等能力)。
    /// </summary>
    public ClipCaps clipCaps => ClipCaps.None;

    /// <summary>
    /// Timeline 创建片段 Playable 时回调:把 Inspector 字段拷贝到 Behaviour。
    /// </summary>
    /// <param name="graph">PlayableGraph。</param>
    /// <param name="owner">拥有者对象(通常是 PlayableDirector 所在对象)。</param>
    /// <returns>该片段对应的 Playable。</returns>
    public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
    {
        // 把 Inspector 字段拷贝给 Behaviour(运行时真正用的是 Behaviour)
        var playable = ScriptPlayable<VayneControlSimpleBehaviour>.Create(graph);
        var behaviour = playable.GetBehaviour();

        behaviour.stateName = stateName;
        behaviour.loopAnimation = loopAnimation;
        behaviour.targetPosition = targetPosition;
        behaviour.restorePositionOnExit = restorePositionOnExit;
        behaviour.restoreAnimationOnExit = restoreAnimationOnExit;

        return playable;
    }
}
  • VayneControlSimpleBehaviour.cs:片段执行逻辑,通过 playerData 拿到轨道绑定的 Animator,每帧输出位置并采样动画。
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Playables;

/// <summary>
/// 无 Mixer 版:单个 Clip 的运行逻辑。
/// 特点:当多个片段重叠时,每个片段都会独立执行 ProcessFrame,可能“互相抢”位置/动画(用于对比)。
/// </summary>
[Serializable]
public class VayneControlSimpleBehaviour : PlayableBehaviour
{
    /// <summary>
    /// 要播放/采样的 Animator State 名(必须与 Animator Controller 里的 State 名一致)。
    /// </summary>
    [Tooltip("要播放/采样的 Animator State 名(必须与 Animator Controller 里的 State 名一致)")]
    public string stateName = "Idle";

    /// <summary>
    /// 是否循环播放动画:
    /// 勾选=按动画原始时长循环;不勾选=只播一次并停在最后一帧。
    /// </summary>
    [Tooltip("是否循环播放动画:勾选=循环;不勾选=播一次停在最后一帧")]
    public bool loopAnimation = false;

    /// <summary>
    /// 角色要被设置到的目标位置(世界坐标)。
    /// </summary>
    [Tooltip("角色要被设置到的目标位置(世界坐标)")]
    public Vector3 targetPosition;

    /// <summary>
    /// 片段退出时:是否恢复到进入片段前的位置。
    /// </summary>
    [Tooltip("片段退出时:是否恢复到进入片段前的位置")]
    public bool restorePositionOnExit = true;

    /// <summary>
    /// 片段退出时:是否恢复到进入片段前的动画状态(Animator 当时正在播放的状态)。
    /// </summary>
    [Tooltip("片段退出时:是否恢复到进入片段前的动画状态(Animator 当时正在播放的状态)")]
    public bool restoreAnimationOnExit = true;

    /// <summary>
    /// 轨道绑定的 Animator(薇恩身上的 Animator)。
    /// 之所以需要缓存:OnBehaviourPause 拿不到 playerData,只能在 ProcessFrame 里记录一次。
    /// </summary>
    private Animator _animator;
    
    /// <summary>
    /// 本片段上一帧是否处于“有权重”的激活态(用于检测进入/退出)。
    /// </summary>
    private bool _wasActive;
    
    /// <summary>
    /// 是否已经记录过“进入片段前”的状态(只记录一次)。
    /// </summary>
    private bool _capturedEnterState;
    
    /// <summary>
    /// 进入片段前角色的世界坐标位置(用于退出恢复)。
    /// </summary>
    private Vector3 _enterWorldPosition;
    
    /// <summary>
    /// 进入片段前 Animator 的状态哈希(fullPathHash,用于退出恢复)。
    /// </summary>
    private int _enterStateHash;
    
    /// <summary>
    /// 进入片段前 Animator 的归一化时间(用于退出恢复到同一帧附近)。
    /// </summary>
    private float _enterNormalizedTime;

    /// <summary>
    /// 动画时长缓存:key=stateName,value=该 State 的时长(秒)。
    /// 用途:按动画原速采样时,需要用“动画时长”而不是 “Clip 时长”。
    /// </summary>
    private readonly Dictionary<string, float> _stateLengthSecondsCache = new();

    /// <summary>
    /// Timeline 每帧评估回调:在此输出位置,并用片段进度采样动画,达到“所见即所得”。
    /// </summary>
    /// <param name="playable">当前片段对应的 Playable。</param>
    /// <param name="info">帧信息(含混合权重)。</param>
    /// <param name="playerData">轨道 Binding 注入对象(本轨道为 Animator)。</param>
    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        // TrackBindingType(typeof(Animator)) 决定 playerData 会是轨道绑定的 Animator
        var animator = playerData as Animator;
        if (animator == null)
            return;

        // 缓存 animator,供退出回调使用
        _animator = animator;

        // 退出:从有权重 -> 无权重(通常表示离开该片段)
        if (_wasActive && info.weight <= 0f)
        {
            RestoreEnterStateIfNeeded();
            _wasActive = false;
            return;
        }

        // 没有权重:本帧不输出
        if (info.weight <= 0f)
            return;

        // 进入:第一次变为有权重时,记录“进入前”的位置与动画,方便退出恢复
        if (!_wasActive)
        {
            CaptureEnterState(animator);
            _wasActive = true;
        }

        // 位置:所见即所得(Timeline 预览/运行时都每帧输出)
        ApplyPosition(animator.transform, targetPosition);

        // 动画:用片段进度驱动 Animator(编辑器拖动时间轴也会即时显示对应帧)
        ApplyAnimationByClipTime(playable, animator);
    }

    /// <summary>
    /// 片段暂停/退出回调:补一层保险,确保离开片段时能恢复进入前状态。
    /// </summary>
    /// <param name="playable">当前片段对应的 Playable。</param>
    /// <param name="info">帧信息。</param>
    public override void OnBehaviourPause(Playable playable, FrameData info)
    {
        // 片段退出时的回调:补一层保险,避免某些情况下没走到 ProcessFrame 的退出分支
        RestoreEnterStateIfNeeded();
        _wasActive = false;
    }

    /// <summary>
    /// 图停止回调:Timeline 停止播放时触发;这里也做一次恢复,保证编辑器/运行时一致。
    /// </summary>
    /// <param name="playable">图对应的 Playable。</param>
    public override void OnGraphStop(Playable playable)
    {
        // 时间轴停止:若片段仍处于激活态,做一次恢复
        RestoreEnterStateIfNeeded();
        _wasActive = false;
        _capturedEnterState = false;
    }

    /// <summary>
    /// 输出世界坐标位置到目标 Transform。
    /// </summary>
    /// <param name="targetTransform">要被设置位置的 Transform。</param>
    /// <param name="position">世界坐标位置。</param>
    private static void ApplyPosition(Transform targetTransform, Vector3 position)
    {
        // 输出世界坐标位置
        targetTransform.position = position;
    }

    /// <summary>
    /// 进入片段时记录“进入前”的位置与动画状态,用于退出时恢复。
    /// </summary>
    /// <param name="animator">轨道绑定的 Animator。</param>
    private void CaptureEnterState(Animator animator)
    {
        // 只记录一次“进入前”的状态(避免运行中被覆盖)
        if (_capturedEnterState)
            return;

        var transform = animator.transform;
        _enterWorldPosition = transform.position;

        // 记录当前动画状态(layer 0)
        var stateInfo = animator.GetCurrentAnimatorStateInfo(0);
        _enterStateHash = stateInfo.fullPathHash;
        _enterNormalizedTime = stateInfo.normalizedTime;

        _capturedEnterState = true;
    }

    /// <summary>
    /// 如有需要,恢复到进入片段前的位置与动画状态。
    /// </summary>
    private void RestoreEnterStateIfNeeded()
    {
        // 没有捕获过进入态/没有 animator:无需恢复
        if (!_capturedEnterState || _animator == null)
            return;

        var transform = _animator.transform;

        // 恢复位置
        if (restorePositionOnExit)
        {
            transform.position = _enterWorldPosition;
        }

        // 恢复动画(恢复到进入时 Animator 正在播的状态)
        if (restoreAnimationOnExit && _enterStateHash != 0)
        {
            _animator.Play(_enterStateHash, 0, _enterNormalizedTime);
            // 关键:让编辑器预览也立即刷新到对应姿势
            _animator.Update(0f);
        }

        _capturedEnterState = false;
    }

    /// <summary>
    /// 用片段时间(time/duration)计算 normalizedTime,并立刻采样动画帧(编辑器预览也生效)。
    /// </summary>
    /// <param name="clipPlayable">当前片段的 Playable。</param>
    /// <param name="animator">轨道绑定的 Animator。</param>
    private void ApplyAnimationByClipTime(Playable clipPlayable, Animator animator)
    {
        if (string.IsNullOrWhiteSpace(stateName))
            return;

        // 按动画原速采样:用“动画时长”作为分母,而不是 Timeline Clip 时长
        float stateLengthSeconds = GetStateLengthSeconds(animator, stateName);
        if (stateLengthSeconds <= 0.0001f)
            return;

        // clipTimeSeconds:当前片段已经播放了多少秒(以 Timeline 为准)。
        float clipTimeSeconds = (float)clipPlayable.GetTime();

        // sampleSeconds:我们要去“采样动画”的时间点(秒)。
        // - 循环:用 Repeat 做取模,让时间落在 [0, 动画时长) 内,从而循环。
        // - 不循环:用 Min 截断到动画时长,超过后就停在最后一帧。
        float sampleSeconds = loopAnimation
            ? Mathf.Repeat(clipTimeSeconds, stateLengthSeconds)
            : Mathf.Min(clipTimeSeconds, stateLengthSeconds); // 不循环:播一次后停在最后一帧

        // normalized:把采样秒数转换为 Animator.Play 需要的归一化时间(0~1)。
        // 公式:normalized = sampleSeconds / stateLengthSeconds
        float normalized = Mathf.Clamp01(sampleSeconds / stateLengthSeconds);

        // 用 normalizedTime 直接采样该动画帧:
        // - 编辑器拖动时间轴会立即看到对应姿势(所见即所得)
        // - 运行时也一致(由 Timeline 时间驱动)
        animator.Play(stateName, 0, normalized);
        animator.Update(0f);
    }

    /// <summary>
    /// 获取指定 State 的时长(秒)。若未缓存,会先强制采样到该 State 再读取 length。
    /// </summary>
    /// <param name="animator">轨道绑定的 Animator。</param>
    /// <param name="state">Animator State 名称。</param>
    /// <returns>该 State 的时长(秒)。</returns>
    private float GetStateLengthSeconds(Animator animator, string state)
    {
        if (string.IsNullOrWhiteSpace(state))
            return 0f;

        if (_stateLengthSecondsCache.TryGetValue(state, out float cached) && cached > 0.0001f)
            return cached;

        // 强制切到目标 State 的第 0 帧,再读取 AnimatorStateInfo.length
        animator.Play(state, 0, 0f);
        animator.Update(0f);

        float length = Mathf.Max(0.0001f, animator.GetCurrentAnimatorStateInfo(0).length);
        _stateLengthSecondsCache[state] = length;
        return length;
    }
}

运行结果:

自定义混合轨道

目标:允许同轨道片段重叠,并且在重叠时行为可控、可解释:

  • 位置:按 Timeline 权重做加权混合(渐入渐出可用)

  • 动画:重叠时用 Animator Layer 做叠加(示例里固定 0.5/0.5,也可以改成“取主导片段”)

  • VayneControlMixerTrack.cs:自定义轨道(重写 CreateTrackMixer())。创建 Mixer,并把 inputCount(同轨道片段数)交给它。

using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

/// <summary>
/// 有 Mixer 版:自定义轨道。
/// - 轨道绑定 Animator(把薇恩的 Animator 拖到轨道 Binding 上)
/// - 轨道里放若干 VayneControlMixerClip
/// - CreateTrackMixer 创建自定义 Mixer:把“重叠片段的冲突”收敛到一处统一处理
/// </summary>
[TrackColor(0.12f, 0.46f, 0.82f)]
[TrackClipType(typeof(VayneControlMixerClip))]
[TrackBindingType(typeof(Animator))]
public class VayneControlMixerTrack : TrackAsset
{
    /// <summary>
    /// 创建新片段时回调:设置一个易读的默认显示名(只影响 Timeline 面板显示)。
    /// </summary>
    /// <param name="clip">新建的 TimelineClip。</param>
    protected override void OnCreateClip(TimelineClip clip)
    {
        // 创建新片段时设置一个易读的默认名字
        base.OnCreateClip(clip);

        if (string.IsNullOrWhiteSpace(clip.displayName))
            clip.displayName = "Vayne (Mixer)";
    }

    /// <summary>
    /// 创建轨道混合器(Mixer)。
    /// Timeline 会把同轨道上的所有 Clip 作为输入喂给 Mixer,实现统一混合与执行。
    /// </summary>
    /// <param name="graph">PlayableGraph。</param>
    /// <param name="go">轨道所在 GameObject。</param>
    /// <param name="inputCount">输入片段数量。</param>
    /// <returns>轨道 Mixer 的 Playable。</returns>
    public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount)
    {
        // inputCount = 同轨道上参与混合的输入片段数量
        return ScriptPlayable<VayneControlMixerTrackMixerBehaviour>.Create(graph, inputCount);
    }
}
  • VayneControlMixerClip.cs:片段资源,只负责“字段 -> Behaviour”,并声明 clipCaps 支持 Blending
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

/// <summary>
/// 有 Mixer 版:Clip 资源(Inspector 上每个片段一份配置)。
/// CreatePlayable 只负责“把字段拷贝到 Behaviour”,不做执行逻辑。
/// </summary>
public class VayneControlMixerClip : PlayableAsset, ITimelineClipAsset
{
    /// <summary>
    /// 要播放/采样的 Animator State 名(必须与 Animator Controller 里的 State 名一致)。
    /// </summary>
    [Tooltip("要播放/采样的 Animator State 名(必须与 Animator Controller 里的 State 名一致)")]
    public string stateName = "Idle";

    /// <summary>
    /// 是否循环播放动画:
    /// 勾选=按动画原始时长循环;不勾选=只播一次并停在最后一帧。
    /// </summary>
    [Tooltip("是否循环播放动画:勾选=循环;不勾选=播一次停在最后一帧")]
    public bool loopAnimation = false;

    /// <summary>
    /// 该片段是否参与位置混合:
    /// true=参与混合输出位置;false=只影响动画,不改位置。
    /// </summary>
    [Tooltip("该片段是否参与位置混合:true=参与混合输出位置;false=只影响动画,不改位置")]
    public bool affectPosition = true;

    /// <summary>
    /// 该片段期望的目标位置(世界坐标),Mixer 会按权重混合多个片段的该值。
    /// </summary>
    [Tooltip("该片段期望的目标位置(世界坐标),Mixer 会按权重混合多个片段的该值")]
    public Vector3 targetPosition;

    /// <summary>
    /// 该片段支持的能力:
    /// - Blending:允许同轨道片段重叠(否则 Timeline 会把重叠“弹回去”)
    /// </summary>
    public ClipCaps clipCaps => ClipCaps.Blending;

    /// <summary>
    /// Timeline 创建片段 Playable 时回调:把 Inspector 字段拷贝到 Behaviour(供 Mixer 读取)。
    /// </summary>
    /// <param name="graph">PlayableGraph。</param>
    /// <param name="owner">拥有者对象(通常是 PlayableDirector 所在对象)。</param>
    /// <returns>该片段对应的 Playable。</returns>
    public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
    {
        // 把 Inspector 字段拷贝给 Behaviour(Mixer 会读取 Behaviour 来做混合/执行)
        var playable = ScriptPlayable<VayneControlMixerBehaviour>.Create(graph);
        var behaviour = playable.GetBehaviour();

        behaviour.stateName = stateName;
        behaviour.loopAnimation = loopAnimation;
        behaviour.affectPosition = affectPosition;
        behaviour.targetPosition = targetPosition;

        return playable;
    }
}
  • VayneControlMixerBehaviour.cs:片段配置数据(每个 Clip 一份,尽量不做执行)
using System;
using UnityEngine;
using UnityEngine.Playables;

/// <summary>
/// 有 Mixer 版:单个 Clip 的“纯配置数据”(不在这里改 Transform / 播动画)。
/// 真正的执行和混合都在 <see cref="VayneControlMixerTrackMixerBehaviour"/> 中完成。
/// </summary>
[Serializable]
public class VayneControlMixerBehaviour : PlayableBehaviour
{
    /// <summary>
    /// 要播放/采样的 Animator State 名(必须与 Animator Controller 里的 State 名一致)。
    /// </summary>
    [Tooltip("要播放/采样的 Animator State 名(必须与 Animator Controller 里的 State 名一致)")]
    public string stateName = "Idle";

    /// <summary>
    /// 是否循环播放动画:
    /// 勾选=按动画原始时长循环;不勾选=只播一次并停在最后一帧。
    /// </summary>
    [Tooltip("是否循环播放动画:勾选=循环;不勾选=播一次停在最后一帧")]
    public bool loopAnimation = false;

    /// <summary>
    /// 该片段是否参与位置混合:
    /// true=参与混合输出位置;false=只影响动画,不改位置。
    /// </summary>
    [Tooltip("该片段是否参与位置混合:true=参与混合输出位置;false=只影响动画,不改位置")]
    public bool affectPosition = true;

    /// <summary>
    /// 该片段期望的目标位置(世界坐标),Mixer 会按权重混合多个片段的该值。
    /// </summary>
    [Tooltip("该片段期望的目标位置(世界坐标),Mixer 会按权重混合多个片段的该值")]
    public Vector3 targetPosition;
}
  • VayneControlMixerTrackMixerBehaviour.cs:Mixer(真正执行 + 混合策略),统一读每个输入片段的 weight + 配置,然后输出位置/动画
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Playables;

/// <summary>
/// 有 Mixer 版:轨道混合器(真正执行)。
/// 规则(简单且易解释):
/// - 位置:所有“affectPosition=true”的片段按权重加权平均输出到 Transform
/// - 动画:权重最大的片段作为“主导片段”,主导变化时触发一次 Play/CrossFade
/// - 退出:最简版不做复位(只负责“当前帧”输出)
/// </summary>
public class VayneControlMixerTrackMixerBehaviour : PlayableBehaviour
{
    /// <summary>
    /// 轨道绑定的 Animator(薇恩身上的 Animator)。
    /// 用途:轨道退出/停止时恢复进入前状态;并用于编辑器预览的即时采样。
    /// </summary>
    private Animator _animator;
    
    /// <summary>
    /// 上一帧轨道是否存在任意有效片段(用于检测轨道进入/退出)。
    /// </summary>
    private bool _trackWasActive;
    
    /// <summary>
    /// 是否已记录“进入轨道前”的状态(只记录一次)。
    /// </summary>
    private bool _capturedEnterState;
    
    /// <summary>
    /// 进入轨道前角色的世界坐标位置(用于轨道退出恢复)。
    /// </summary>
    private Vector3 _enterWorldPosition;
    
    /// <summary>
    /// 进入轨道前 Animator 的状态哈希(fullPathHash,用于轨道退出恢复)。
    /// </summary>
    private int _enterStateHash;
    
    /// <summary>
    /// 进入轨道前 Animator 的归一化时间(用于轨道退出恢复到同一帧附近)。
    /// </summary>
    private float _enterNormalizedTime;

    /// <summary>
    /// 进入轨道前 Layer0/Layer1 的权重(用于轨道退出恢复)。
    /// </summary>
    private float _enterLayer0Weight;
    private float _enterLayer1Weight;

    /// <summary>
    /// 进入轨道前 Layer1 的动画状态(用于轨道退出恢复)。
    /// </summary>
    private int _enterLayer1StateHash;
    private float _enterLayer1NormalizedTime;

    /// <summary>
    /// 动画时长缓存:key=stateName,value=该 State 的时长(秒)。
    /// 用途:按动画原速采样时,需要用“动画时长”而不是 “Clip 时长”。
    /// </summary>
    private readonly Dictionary<string, float> _stateLengthSecondsCache = new();

    /// <summary>
    /// Timeline 每帧评估回调:统一进行位置混合与动画采样,避免多个 Clip “抢控制”。
    /// </summary>
    /// <param name="playable">轨道 Mixer 的 Playable(其输入为同轨道上的所有片段)。</param>
    /// <param name="info">帧信息。</param>
    /// <param name="playerData">轨道 Binding 注入对象(本轨道为 Animator)。</param>
    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        // TrackBindingType(typeof(Animator)) 决定 playerData 会是轨道绑定的 Animator
        // 也就是说:你在 Timeline 轨道 Binding 里拖的那个 Animator,会在这里传进来
        var animator = playerData as Animator;
        if (animator == null)
            return;

        // 缓存 animator,供“轨道退出恢复”使用(RestoreEnterStateIfNeeded)
        _animator = animator;

        int inputCount = playable.GetInputCount();

        // 统计总权重,并取出前两个有效片段(用于 Layer0/Layer1 混合)
        // 注意:这里“有效”指 GetInputWeight(i) > 0
        // - 不重叠时:通常只有 1 个有效片段
        // - 重叠时:会同时出现 2 个有效片段
        float totalWeight = 0f;
        int activeCount = 0;
        int activeIndex0 = -1;
        int activeIndex1 = -1;

        for (int i = 0; i < inputCount; i++)
        {
            float inputWeight = playable.GetInputWeight(i);
            if (inputWeight <= 0f)
                continue;

            totalWeight += inputWeight;

            // 只记录前两个有效片段:因为我们用 2 个 Animator Layer 做混合(Layer0/Layer1)
            if (activeCount == 0) activeIndex0 = i;
            else if (activeCount == 1) activeIndex1 = i;

            activeCount++;
        }

        // 没有任何片段生效:本帧不输出
        // 这里用 totalWeight 判断轨道“是否激活”,用于进入/退出检测
        bool trackIsActiveNow = totalWeight > 0f;

        // 轨道退出:从有 -> 无(恢复进入轨道前的位置与动画)
        if (_trackWasActive && !trackIsActiveNow)
        {
            // 轨道整体退出:把角色恢复到进入轨道前的状态(位置 + Layer 权重 + Layer 动画)
            RestoreEnterStateIfNeeded();
            _trackWasActive = false;
            return;
        }

        // 轨道未激活:不输出
        if (!trackIsActiveNow)
            return;

        // 轨道进入:第一次变为有权重时记录进入前状态
        if (!_trackWasActive)
        {
            // 进入轨道:记录当前的位置/动画/Layer 权重,方便之后退出时恢复
            CaptureEnterState(animator);
            _trackWasActive = true;
        }

        // 位置混合:按 Timeline 权重混合(只混合 affectPosition=true 的片段)
        // 说明:位置混合用的是 Timeline 自带权重(可渐入渐出),与动画“固定 0.5/0.5”是两套规则
        if (activeCount > 0)
        {
            float positionWeightSum = 0f;
            Vector3 blended = Vector3.zero;

            for (int i = 0; i < inputCount; i++)
            {
                float w = playable.GetInputWeight(i);
                if (w <= 0f)
                    continue;

                var inputPlayable = (ScriptPlayable<VayneControlMixerBehaviour>)playable.GetInput(i);
                var b = inputPlayable.GetBehaviour();

                // 如果该片段不参与位置混合,就跳过(它只影响动画)
                if (!b.affectPosition)
                    continue;

                blended += b.targetPosition * w;
                positionWeightSum += w;
            }

            if (positionWeightSum > 0f)
            {
                // 权重归一化:把加权和除以权重总和,得到最终输出位置
                blended /= positionWeightSum;
                ApplyPosition(animator.transform, blended);
            }
        }

        // 动画:Animator 多 Layer 混合
        // 关键点:我们不使用 Animator Controller 的参数/BlendTree,而是利用 Layer 权重做 2 段动画的叠加
        if (activeCount <= 0)
            return;

        bool hasSecondLayer = animator.layerCount >= 2;

        if (activeCount == 1 || !hasSecondLayer)
        {
            // 单片段:Layer0=1,Layer1=0
            animator.SetLayerWeight(0, 1f);
            if (hasSecondLayer)
                animator.SetLayerWeight(1, 0f);

            // 把该片段的 stateName 按“动画原速 + 是否循环”的规则采样到 Layer0
            var clipPlayable = (ScriptPlayable<VayneControlMixerBehaviour>)playable.GetInput(activeIndex0);
            var behaviour = clipPlayable.GetBehaviour();
            PlayClipOnLayer(clipPlayable, animator, behaviour, 0);
            animator.Update(0f);
            return;
        }

        // 重叠:固定 0.5/0.5(忽略 Timeline 自带的渐入/渐出权重),取前两个片段做混合
        // 这正是你提出的规则:只要发生重叠,就各 0.5;不重叠则为 1
        animator.SetLayerWeight(0, 0.5f);
        animator.SetLayerWeight(1, 0.5f);

        var clipPlayableA = (ScriptPlayable<VayneControlMixerBehaviour>)playable.GetInput(activeIndex0);
        var clipPlayableB = (ScriptPlayable<VayneControlMixerBehaviour>)playable.GetInput(activeIndex1);
        var behaviourA = clipPlayableA.GetBehaviour();
        var behaviourB = clipPlayableB.GetBehaviour();

        // 约定:第一个有效片段播放到 Layer0,第二个有效片段播放到 Layer1
        PlayClipOnLayer(clipPlayableA, animator, behaviourA, 0);
        PlayClipOnLayer(clipPlayableB, animator, behaviourB, 1);

        // 关键:Update(0f) 立刻刷新到当前采样帧,保证“编辑器所见即所得”
        animator.Update(0f);
    }

    /// <summary>
    /// 图停止回调:Timeline 停止播放时触发;这里做恢复并清理缓存,保证下次播放一致。
    /// </summary>
    /// <param name="playable">图对应的 Playable。</param>
    public override void OnGraphStop(Playable playable)
    {
        // 时间轴停止时清理缓存:确保下次播放能触发一次 Play
        RestoreEnterStateIfNeeded();
        _trackWasActive = false;
        _capturedEnterState = false;
    }

    /// <summary>
    /// 按动画原速采样并播放到指定 Layer(编辑器拖动时间轴也能看到对应帧)。
    /// </summary>
    /// <param name="clipPlayable">片段的 Playable(用于取时间)。</param>
    /// <param name="animator">轨道绑定的 Animator。</param>
    /// <param name="clipConfig">片段配置数据。</param>
    /// <param name="layerIndex">要播放到的 Animator Layer。</param>
    private void PlayClipOnLayer(Playable clipPlayable, Animator animator, VayneControlMixerBehaviour clipConfig, int layerIndex)
    {
        if (string.IsNullOrWhiteSpace(clipConfig.stateName))
            return;

        // 按动画原速采样:用“动画时长”作为分母,而不是 Timeline Clip 时长
        float stateLengthSeconds = GetStateLengthSeconds(animator, layerIndex, clipConfig.stateName);
        if (stateLengthSeconds <= 0.0001f)
            return;

        float clipTimeSeconds = (float)clipPlayable.GetTime();
        float sampleSeconds = clipConfig.loopAnimation
            ? Mathf.Repeat(clipTimeSeconds, stateLengthSeconds)
            : Mathf.Min(clipTimeSeconds, stateLengthSeconds); // 不循环:播一次后停在最后一帧

        float normalized = Mathf.Clamp01(sampleSeconds / stateLengthSeconds);

        // 注意:Update(0f) 在 ProcessFrame 最后统一调用,这里只负责设置播放点
        animator.Play(clipConfig.stateName, layerIndex, normalized);
    }

    /// <summary>
    /// 获取指定 State 的时长(秒)。若未缓存,会先强制采样到该 State 再读取 length。
    /// </summary>
    /// <param name="animator">轨道绑定的 Animator。</param>
    /// <param name="layerIndex">Animator Layer 索引。</param>
    /// <param name="state">Animator State 名称。</param>
    /// <returns>该 State 的时长(秒)。</returns>
    private float GetStateLengthSeconds(Animator animator, int layerIndex, string state)
    {
        if (string.IsNullOrWhiteSpace(state))
            return 0f;

        if (_stateLengthSecondsCache.TryGetValue(state, out float cached) && cached > 0.0001f)
            return cached;

        // 强制切到目标 State 的第 0 帧,再读取 AnimatorStateInfo.length
        animator.Play(state, layerIndex, 0f);
        animator.Update(0f);

        float length = Mathf.Max(0.0001f, animator.GetCurrentAnimatorStateInfo(layerIndex).length);
        _stateLengthSecondsCache[state] = length;
        return length;
    }

    /// <summary>
    /// 输出世界坐标位置到目标 Transform。
    /// </summary>
    /// <param name="targetTransform">要被设置位置的 Transform。</param>
    /// <param name="position">世界坐标位置。</param>
    private static void ApplyPosition(Transform targetTransform, Vector3 position)
    {
        // 输出世界坐标位置
        targetTransform.position = position;
    }

    /// <summary>
    /// 进入轨道时记录“进入前”的位置与动画状态,用于轨道退出时恢复。
    /// </summary>
    /// <param name="animator">轨道绑定的 Animator。</param>
    private void CaptureEnterState(Animator animator)
    {
        // 记录一次进入前状态,供轨道退出时恢复
        if (_capturedEnterState)
            return;

        var tr = animator.transform;
        _enterWorldPosition = tr.position;

        // 记录 Layer0
        _enterLayer0Weight = animator.GetLayerWeight(0);
        var stateInfo0 = animator.GetCurrentAnimatorStateInfo(0);
        _enterStateHash = stateInfo0.fullPathHash;
        _enterNormalizedTime = stateInfo0.normalizedTime;

        // 记录 Layer1(如果存在)
        if (animator.layerCount >= 2)
        {
            _enterLayer1Weight = animator.GetLayerWeight(1);
            var stateInfo1 = animator.GetCurrentAnimatorStateInfo(1);
            _enterLayer1StateHash = stateInfo1.fullPathHash;
            _enterLayer1NormalizedTime = stateInfo1.normalizedTime;
        }
        else
        {
            _enterLayer1Weight = 0f;
            _enterLayer1StateHash = 0;
            _enterLayer1NormalizedTime = 0f;
        }

        _capturedEnterState = true;
    }

    /// <summary>
    /// 如有需要,恢复到进入轨道前的位置与动画状态。
    /// </summary>
    private void RestoreEnterStateIfNeeded()
    {
        if (!_capturedEnterState || _animator == null)
            return;

        var tr = _animator.transform;
        tr.position = _enterWorldPosition;

        // 恢复 Layer 权重
        _animator.SetLayerWeight(0, _enterLayer0Weight);
        if (_animator.layerCount >= 2)
            _animator.SetLayerWeight(1, _enterLayer1Weight);

        if (_enterStateHash != 0)
        {
            _animator.Play(_enterStateHash, 0, _enterNormalizedTime);
        }

        // 恢复 Layer1 动画(如果存在)
        if (_animator.layerCount >= 2 && _enterLayer1StateHash != 0)
        {
            _animator.Play(_enterLayer1StateHash, 1, _enterLayer1NormalizedTime);
        }

        _animator.Update(0f);

        _capturedEnterState = false;
    }
}

运行结果:


2.2 知识点代码

ChangeObjPosPlayableAsset.cs

using UnityEngine;
using UnityEngine.Playables;

public class ChangeObjPosPlayableAsset : PlayableAsset
{
    public GameObject GameObj;
    public Vector3 Position;

    public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
    {
        ChangeObjPosPlayableBehaviour changeObjPosPlayableBehaviour = new ChangeObjPosPlayableBehaviour();
        changeObjPosPlayableBehaviour.GameObj = GameObj;
        changeObjPosPlayableBehaviour.Position = Position;
        return ScriptPlayable<ChangeObjPosPlayableBehaviour>.Create(graph, changeObjPosPlayableBehaviour);
    }
}

ChangeObjPosPlayableBehaviour.cs

using UnityEngine;
using UnityEngine.Playables;

public class ChangeObjPosPlayableBehaviour : PlayableBehaviour
{
    public GameObject GameObj;
    public Vector3 Position;
    public float Time;

    public override void OnGraphStart(Playable playable)
    {
        base.OnGraphStart(playable);
        Debug.Log("OnGraphStart=======================");
    }

    public override void OnGraphStop(Playable playable)
    {
        base.OnGraphStop(playable);
        Debug.Log("OnGraphStop=======================");
    }

    public override void OnBehaviourPlay(Playable playable, FrameData info)
    {
        base.OnBehaviourPlay(playable, info);
        Debug.Log("OnBehaviourPlay=======================");

        if (null != GameObj)
        {
            // 这里的逻辑是改变物体的坐标,具体逻辑就看具体需求
            GameObj.transform.position = Position;
        }
    }

    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        base.ProcessFrame(playable, info, playerData);
        Debug.Log("ProcessFrame======================= Position: " + Position);
        Time += info.deltaTime;
        if (null != GameObj)
        {
            // 这里的逻辑是改变物体的坐标,具体逻辑就看具体需求
            GameObj.transform.position = Position + new Vector3(Time, info.deltaTime, Time);
        }
    }

    public override void OnBehaviourPause(Playable playable, FrameData info)
    {
        base.OnBehaviourPause(playable, info);
        Debug.Log("OnBehaviourPause=======================");

        if (null != GameObj)
        {
            GameObj.transform.position = Vector3.zero;
        }
    }
}

ChangeObjPosPlayableAssetBinder.cs

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

public class ChangeObjPosPlayableAssetBinder : MonoBehaviour
{
    public PlayableDirector playableDirector;
    public GameObject vayne;
    public Vector3 position;
    private ChangeObjPosPlayableAsset _changeObjPosPlayableAsset;

    void Start()
    {
        PlayableAsset timelinePlayableAsset = playableDirector.playableAsset;
        foreach (var binding in timelinePlayableAsset.outputs)
        {
            // 轨道名
            var trackName = binding.streamName;
            if ("MyPlayableTest" == trackName)
            {
                // 轨道
                TrackAsset trackAsset = binding.sourceObject as TrackAsset;
                if (trackAsset == null) continue;

                // 轨道上的片段,每个片段都是PlayableAsset的子类
                IEnumerable<TimelineClip> timelineClipList = trackAsset.GetClips();
                foreach (var timelineClip in timelineClipList)
                {
                    if (timelineClip.asset is not ChangeObjPosPlayableAsset asset) continue;

                    _changeObjPosPlayableAsset = asset;

                    // 动态设置go对象的坐标
                    if (_changeObjPosPlayableAsset != null)
                    {
                        _changeObjPosPlayableAsset.GameObj = vayne;
                        _changeObjPosPlayableAsset.Position = position;
                    }

                    break;
                }
            }
        }
    }
}

SignalReceiverTest.cs

using UnityEngine;
using UnityEngine.Timeline;

[RequireComponent(typeof(SignalReceiver))]
public class SignalReceiverTest : MonoBehaviour
{
    // 信号的响应函数
    public void OnReceiveSignal()
    {
        Debug.Log("OnReceiveSignal: 接收到信号!");
    }
}

VayneControlSimpleTrack.cs

using UnityEngine;
using UnityEngine.Timeline;

/// <summary>
/// 无 Mixer 版:自定义轨道(不重写 CreateTrackMixer)。
/// - 轨道绑定 Animator(把薇恩的 Animator 拖到轨道 Binding 上)
/// - 轨道里放若干 VayneControlSimpleClip
/// 
/// 对比点:片段重叠时,各自 Behaviour 会同时执行,控制会互相覆盖。
/// </summary>
[TrackColor(0.20f, 0.70f, 0.20f)]
[TrackClipType(typeof(VayneControlSimpleClip))]
[TrackBindingType(typeof(Animator))]
public class VayneControlSimpleTrack : TrackAsset
{
    /// <summary>
    /// 创建新片段时回调:设置一个易读的默认显示名(只影响 Timeline 面板显示)。
    /// </summary>
    /// <param name="clip">新建的 TimelineClip。</param>
    protected override void OnCreateClip(TimelineClip clip)
    {
        // 创建新片段时设置一个易读的默认名字
        base.OnCreateClip(clip);

        if (string.IsNullOrWhiteSpace(clip.displayName))
            clip.displayName = "Vayne (Simple)";
    }
}

VayneControlSimpleClip.cs

using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

/// <summary>
/// 无 Mixer 版:Clip 资源(Inspector 上每个片段一份配置)。
/// CreatePlayable 时把 Inspector 字段拷贝到 Behaviour,Behaviour 在每帧执行逻辑。
/// </summary>
public class VayneControlSimpleClip : PlayableAsset, ITimelineClipAsset
{
    /// <summary>
    /// 要播放/采样的 Animator State 名(必须与 Animator Controller 里的 State 名一致)。
    /// </summary>
    [Tooltip("要播放/采样的 Animator State 名(必须与 Animator Controller 里的 State 名一致)")]
    public string stateName = "Idle";

    /// <summary>
    /// 是否循环播放动画:
    /// 勾选=按动画原始时长循环;不勾选=只播一次并停在最后一帧。
    /// </summary>
    [Tooltip("是否循环播放动画:勾选=循环;不勾选=播一次停在最后一帧")]
    public bool loopAnimation = false;

    /// <summary>
    /// 角色要被设置到的目标位置(世界坐标)。
    /// </summary>
    [Tooltip("角色要被设置到的目标位置(世界坐标)")]
    public Vector3 targetPosition;

    /// <summary>
    /// 片段退出时:是否恢复到进入片段前的位置。
    /// </summary>
    [Header("Exit")]
    [Tooltip("片段退出时:是否恢复到进入片段前的位置")]
    public bool restorePositionOnExit = true;

    /// <summary>
    /// 片段退出时:是否恢复到进入片段前的动画状态(Animator 当时正在播放的状态)。
    /// </summary>
    [Tooltip("片段退出时:是否恢复到进入片段前的动画状态(Animator 当时正在播放的状态)")]
    public bool restoreAnimationOnExit = true;

    /// <summary>
    /// 该片段支持的能力;这里保持最简(不声明 Loop/Speed 等能力)。
    /// </summary>
    public ClipCaps clipCaps => ClipCaps.None;

    /// <summary>
    /// Timeline 创建片段 Playable 时回调:把 Inspector 字段拷贝到 Behaviour。
    /// </summary>
    /// <param name="graph">PlayableGraph。</param>
    /// <param name="owner">拥有者对象(通常是 PlayableDirector 所在对象)。</param>
    /// <returns>该片段对应的 Playable。</returns>
    public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
    {
        // 把 Inspector 字段拷贝给 Behaviour(运行时真正用的是 Behaviour)
        var playable = ScriptPlayable<VayneControlSimpleBehaviour>.Create(graph);
        var behaviour = playable.GetBehaviour();

        behaviour.stateName = stateName;
        behaviour.loopAnimation = loopAnimation;
        behaviour.targetPosition = targetPosition;
        behaviour.restorePositionOnExit = restorePositionOnExit;
        behaviour.restoreAnimationOnExit = restoreAnimationOnExit;

        return playable;
    }
}

VayneControlSimpleBehaviour.cs

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Playables;

/// <summary>
/// 无 Mixer 版:单个 Clip 的运行逻辑。
/// 特点:当多个片段重叠时,每个片段都会独立执行 ProcessFrame,可能“互相抢”位置/动画(用于对比)。
/// </summary>
[Serializable]
public class VayneControlSimpleBehaviour : PlayableBehaviour
{
    /// <summary>
    /// 要播放/采样的 Animator State 名(必须与 Animator Controller 里的 State 名一致)。
    /// </summary>
    [Tooltip("要播放/采样的 Animator State 名(必须与 Animator Controller 里的 State 名一致)")]
    public string stateName = "Idle";

    /// <summary>
    /// 是否循环播放动画:
    /// 勾选=按动画原始时长循环;不勾选=只播一次并停在最后一帧。
    /// </summary>
    [Tooltip("是否循环播放动画:勾选=循环;不勾选=播一次停在最后一帧")]
    public bool loopAnimation = false;

    /// <summary>
    /// 角色要被设置到的目标位置(世界坐标)。
    /// </summary>
    [Tooltip("角色要被设置到的目标位置(世界坐标)")]
    public Vector3 targetPosition;

    /// <summary>
    /// 片段退出时:是否恢复到进入片段前的位置。
    /// </summary>
    [Tooltip("片段退出时:是否恢复到进入片段前的位置")]
    public bool restorePositionOnExit = true;

    /// <summary>
    /// 片段退出时:是否恢复到进入片段前的动画状态(Animator 当时正在播放的状态)。
    /// </summary>
    [Tooltip("片段退出时:是否恢复到进入片段前的动画状态(Animator 当时正在播放的状态)")]
    public bool restoreAnimationOnExit = true;

    /// <summary>
    /// 轨道绑定的 Animator(薇恩身上的 Animator)。
    /// 之所以需要缓存:OnBehaviourPause 拿不到 playerData,只能在 ProcessFrame 里记录一次。
    /// </summary>
    private Animator _animator;
    
    /// <summary>
    /// 本片段上一帧是否处于“有权重”的激活态(用于检测进入/退出)。
    /// </summary>
    private bool _wasActive;
    
    /// <summary>
    /// 是否已经记录过“进入片段前”的状态(只记录一次)。
    /// </summary>
    private bool _capturedEnterState;
    
    /// <summary>
    /// 进入片段前角色的世界坐标位置(用于退出恢复)。
    /// </summary>
    private Vector3 _enterWorldPosition;
    
    /// <summary>
    /// 进入片段前 Animator 的状态哈希(fullPathHash,用于退出恢复)。
    /// </summary>
    private int _enterStateHash;
    
    /// <summary>
    /// 进入片段前 Animator 的归一化时间(用于退出恢复到同一帧附近)。
    /// </summary>
    private float _enterNormalizedTime;

    /// <summary>
    /// 动画时长缓存:key=stateName,value=该 State 的时长(秒)。
    /// 用途:按动画原速采样时,需要用“动画时长”而不是 “Clip 时长”。
    /// </summary>
    private readonly Dictionary<string, float> _stateLengthSecondsCache = new();

    /// <summary>
    /// Timeline 每帧评估回调:在此输出位置,并用片段进度采样动画,达到“所见即所得”。
    /// </summary>
    /// <param name="playable">当前片段对应的 Playable。</param>
    /// <param name="info">帧信息(含混合权重)。</param>
    /// <param name="playerData">轨道 Binding 注入对象(本轨道为 Animator)。</param>
    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        // TrackBindingType(typeof(Animator)) 决定 playerData 会是轨道绑定的 Animator
        var animator = playerData as Animator;
        if (animator == null)
            return;

        // 缓存 animator,供退出回调使用
        _animator = animator;

        // 退出:从有权重 -> 无权重(通常表示离开该片段)
        if (_wasActive && info.weight <= 0f)
        {
            RestoreEnterStateIfNeeded();
            _wasActive = false;
            return;
        }

        // 没有权重:本帧不输出
        if (info.weight <= 0f)
            return;

        // 进入:第一次变为有权重时,记录“进入前”的位置与动画,方便退出恢复
        if (!_wasActive)
        {
            CaptureEnterState(animator);
            _wasActive = true;
        }

        // 位置:所见即所得(Timeline 预览/运行时都每帧输出)
        ApplyPosition(animator.transform, targetPosition);

        // 动画:用片段进度驱动 Animator(编辑器拖动时间轴也会即时显示对应帧)
        ApplyAnimationByClipTime(playable, animator);
    }

    /// <summary>
    /// 片段暂停/退出回调:补一层保险,确保离开片段时能恢复进入前状态。
    /// </summary>
    /// <param name="playable">当前片段对应的 Playable。</param>
    /// <param name="info">帧信息。</param>
    public override void OnBehaviourPause(Playable playable, FrameData info)
    {
        // 片段退出时的回调:补一层保险,避免某些情况下没走到 ProcessFrame 的退出分支
        RestoreEnterStateIfNeeded();
        _wasActive = false;
    }

    /// <summary>
    /// 图停止回调:Timeline 停止播放时触发;这里也做一次恢复,保证编辑器/运行时一致。
    /// </summary>
    /// <param name="playable">图对应的 Playable。</param>
    public override void OnGraphStop(Playable playable)
    {
        // 时间轴停止:若片段仍处于激活态,做一次恢复
        RestoreEnterStateIfNeeded();
        _wasActive = false;
        _capturedEnterState = false;
    }

    /// <summary>
    /// 输出世界坐标位置到目标 Transform。
    /// </summary>
    /// <param name="targetTransform">要被设置位置的 Transform。</param>
    /// <param name="position">世界坐标位置。</param>
    private static void ApplyPosition(Transform targetTransform, Vector3 position)
    {
        // 输出世界坐标位置
        targetTransform.position = position;
    }

    /// <summary>
    /// 进入片段时记录“进入前”的位置与动画状态,用于退出时恢复。
    /// </summary>
    /// <param name="animator">轨道绑定的 Animator。</param>
    private void CaptureEnterState(Animator animator)
    {
        // 只记录一次“进入前”的状态(避免运行中被覆盖)
        if (_capturedEnterState)
            return;

        var transform = animator.transform;
        _enterWorldPosition = transform.position;

        // 记录当前动画状态(layer 0)
        var stateInfo = animator.GetCurrentAnimatorStateInfo(0);
        _enterStateHash = stateInfo.fullPathHash;
        _enterNormalizedTime = stateInfo.normalizedTime;

        _capturedEnterState = true;
    }

    /// <summary>
    /// 如有需要,恢复到进入片段前的位置与动画状态。
    /// </summary>
    private void RestoreEnterStateIfNeeded()
    {
        // 没有捕获过进入态/没有 animator:无需恢复
        if (!_capturedEnterState || _animator == null)
            return;

        var transform = _animator.transform;

        // 恢复位置
        if (restorePositionOnExit)
        {
            transform.position = _enterWorldPosition;
        }

        // 恢复动画(恢复到进入时 Animator 正在播的状态)
        if (restoreAnimationOnExit && _enterStateHash != 0)
        {
            _animator.Play(_enterStateHash, 0, _enterNormalizedTime);
            // 关键:让编辑器预览也立即刷新到对应姿势
            _animator.Update(0f);
        }

        _capturedEnterState = false;
    }

    /// <summary>
    /// 用片段时间(time/duration)计算 normalizedTime,并立刻采样动画帧(编辑器预览也生效)。
    /// </summary>
    /// <param name="clipPlayable">当前片段的 Playable。</param>
    /// <param name="animator">轨道绑定的 Animator。</param>
    private void ApplyAnimationByClipTime(Playable clipPlayable, Animator animator)
    {
        if (string.IsNullOrWhiteSpace(stateName))
            return;

        // 按动画原速采样:用“动画时长”作为分母,而不是 Timeline Clip 时长
        float stateLengthSeconds = GetStateLengthSeconds(animator, stateName);
        if (stateLengthSeconds <= 0.0001f)
            return;

        // clipTimeSeconds:当前片段已经播放了多少秒(以 Timeline 为准)。
        float clipTimeSeconds = (float)clipPlayable.GetTime();

        // sampleSeconds:我们要去“采样动画”的时间点(秒)。
        // - 循环:用 Repeat 做取模,让时间落在 [0, 动画时长) 内,从而循环。
        // - 不循环:用 Min 截断到动画时长,超过后就停在最后一帧。
        float sampleSeconds = loopAnimation
            ? Mathf.Repeat(clipTimeSeconds, stateLengthSeconds)
            : Mathf.Min(clipTimeSeconds, stateLengthSeconds); // 不循环:播一次后停在最后一帧

        // normalized:把采样秒数转换为 Animator.Play 需要的归一化时间(0~1)。
        // 公式:normalized = sampleSeconds / stateLengthSeconds
        float normalized = Mathf.Clamp01(sampleSeconds / stateLengthSeconds);

        // 用 normalizedTime 直接采样该动画帧:
        // - 编辑器拖动时间轴会立即看到对应姿势(所见即所得)
        // - 运行时也一致(由 Timeline 时间驱动)
        animator.Play(stateName, 0, normalized);
        animator.Update(0f);
    }

    /// <summary>
    /// 获取指定 State 的时长(秒)。若未缓存,会先强制采样到该 State 再读取 length。
    /// </summary>
    /// <param name="animator">轨道绑定的 Animator。</param>
    /// <param name="state">Animator State 名称。</param>
    /// <returns>该 State 的时长(秒)。</returns>
    private float GetStateLengthSeconds(Animator animator, string state)
    {
        if (string.IsNullOrWhiteSpace(state))
            return 0f;

        if (_stateLengthSecondsCache.TryGetValue(state, out float cached) && cached > 0.0001f)
            return cached;

        // 强制切到目标 State 的第 0 帧,再读取 AnimatorStateInfo.length
        animator.Play(state, 0, 0f);
        animator.Update(0f);

        float length = Mathf.Max(0.0001f, animator.GetCurrentAnimatorStateInfo(0).length);
        _stateLengthSecondsCache[state] = length;
        return length;
    }
}

VayneControlMixerTrack.cs

using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

/// <summary>
/// 有 Mixer 版:自定义轨道。
/// - 轨道绑定 Animator(把薇恩的 Animator 拖到轨道 Binding 上)
/// - 轨道里放若干 VayneControlMixerClip
/// - CreateTrackMixer 创建自定义 Mixer:把“重叠片段的冲突”收敛到一处统一处理
/// </summary>
[TrackColor(0.12f, 0.46f, 0.82f)]
[TrackClipType(typeof(VayneControlMixerClip))]
[TrackBindingType(typeof(Animator))]
public class VayneControlMixerTrack : TrackAsset
{
    /// <summary>
    /// 创建新片段时回调:设置一个易读的默认显示名(只影响 Timeline 面板显示)。
    /// </summary>
    /// <param name="clip">新建的 TimelineClip。</param>
    protected override void OnCreateClip(TimelineClip clip)
    {
        // 创建新片段时设置一个易读的默认名字
        base.OnCreateClip(clip);

        if (string.IsNullOrWhiteSpace(clip.displayName))
            clip.displayName = "Vayne (Mixer)";
    }

    /// <summary>
    /// 创建轨道混合器(Mixer)。
    /// Timeline 会把同轨道上的所有 Clip 作为输入喂给 Mixer,实现统一混合与执行。
    /// </summary>
    /// <param name="graph">PlayableGraph。</param>
    /// <param name="go">轨道所在 GameObject。</param>
    /// <param name="inputCount">输入片段数量。</param>
    /// <returns>轨道 Mixer 的 Playable。</returns>
    public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount)
    {
        // inputCount = 同轨道上参与混合的输入片段数量
        return ScriptPlayable<VayneControlMixerTrackMixerBehaviour>.Create(graph, inputCount);
    }
}

VayneControlMixerClip.cs

using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

/// <summary>
/// 有 Mixer 版:Clip 资源(Inspector 上每个片段一份配置)。
/// CreatePlayable 只负责“把字段拷贝到 Behaviour”,不做执行逻辑。
/// </summary>
public class VayneControlMixerClip : PlayableAsset, ITimelineClipAsset
{
    /// <summary>
    /// 要播放/采样的 Animator State 名(必须与 Animator Controller 里的 State 名一致)。
    /// </summary>
    [Tooltip("要播放/采样的 Animator State 名(必须与 Animator Controller 里的 State 名一致)")]
    public string stateName = "Idle";

    /// <summary>
    /// 是否循环播放动画:
    /// 勾选=按动画原始时长循环;不勾选=只播一次并停在最后一帧。
    /// </summary>
    [Tooltip("是否循环播放动画:勾选=循环;不勾选=播一次停在最后一帧")]
    public bool loopAnimation = false;

    /// <summary>
    /// 该片段是否参与位置混合:
    /// true=参与混合输出位置;false=只影响动画,不改位置。
    /// </summary>
    [Tooltip("该片段是否参与位置混合:true=参与混合输出位置;false=只影响动画,不改位置")]
    public bool affectPosition = true;

    /// <summary>
    /// 该片段期望的目标位置(世界坐标),Mixer 会按权重混合多个片段的该值。
    /// </summary>
    [Tooltip("该片段期望的目标位置(世界坐标),Mixer 会按权重混合多个片段的该值")]
    public Vector3 targetPosition;

    /// <summary>
    /// 该片段支持的能力:
    /// - Blending:允许同轨道片段重叠(否则 Timeline 会把重叠“弹回去”)
    /// </summary>
    public ClipCaps clipCaps => ClipCaps.Blending;

    /// <summary>
    /// Timeline 创建片段 Playable 时回调:把 Inspector 字段拷贝到 Behaviour(供 Mixer 读取)。
    /// </summary>
    /// <param name="graph">PlayableGraph。</param>
    /// <param name="owner">拥有者对象(通常是 PlayableDirector 所在对象)。</param>
    /// <returns>该片段对应的 Playable。</returns>
    public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
    {
        // 把 Inspector 字段拷贝给 Behaviour(Mixer 会读取 Behaviour 来做混合/执行)
        var playable = ScriptPlayable<VayneControlMixerBehaviour>.Create(graph);
        var behaviour = playable.GetBehaviour();

        behaviour.stateName = stateName;
        behaviour.loopAnimation = loopAnimation;
        behaviour.affectPosition = affectPosition;
        behaviour.targetPosition = targetPosition;

        return playable;
    }
}

VayneControlMixerBehaviour.cs

using System;
using UnityEngine;
using UnityEngine.Playables;

/// <summary>
/// 有 Mixer 版:单个 Clip 的“纯配置数据”(不在这里改 Transform / 播动画)。
/// 真正的执行和混合都在 <see cref="VayneControlMixerTrackMixerBehaviour"/> 中完成。
/// </summary>
[Serializable]
public class VayneControlMixerBehaviour : PlayableBehaviour
{
    /// <summary>
    /// 要播放/采样的 Animator State 名(必须与 Animator Controller 里的 State 名一致)。
    /// </summary>
    [Tooltip("要播放/采样的 Animator State 名(必须与 Animator Controller 里的 State 名一致)")]
    public string stateName = "Idle";

    /// <summary>
    /// 是否循环播放动画:
    /// 勾选=按动画原始时长循环;不勾选=只播一次并停在最后一帧。
    /// </summary>
    [Tooltip("是否循环播放动画:勾选=循环;不勾选=播一次停在最后一帧")]
    public bool loopAnimation = false;

    /// <summary>
    /// 该片段是否参与位置混合:
    /// true=参与混合输出位置;false=只影响动画,不改位置。
    /// </summary>
    [Tooltip("该片段是否参与位置混合:true=参与混合输出位置;false=只影响动画,不改位置")]
    public bool affectPosition = true;

    /// <summary>
    /// 该片段期望的目标位置(世界坐标),Mixer 会按权重混合多个片段的该值。
    /// </summary>
    [Tooltip("该片段期望的目标位置(世界坐标),Mixer 会按权重混合多个片段的该值")]
    public Vector3 targetPosition;
}

VayneControlMixerTrackMixerBehaviour.cs

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Playables;

/// <summary>
/// 有 Mixer 版:轨道混合器(真正执行)。
/// 规则(简单且易解释):
/// - 位置:所有“affectPosition=true”的片段按权重加权平均输出到 Transform
/// - 动画:权重最大的片段作为“主导片段”,主导变化时触发一次 Play/CrossFade
/// - 退出:最简版不做复位(只负责“当前帧”输出)
/// </summary>
public class VayneControlMixerTrackMixerBehaviour : PlayableBehaviour
{
    /// <summary>
    /// 轨道绑定的 Animator(薇恩身上的 Animator)。
    /// 用途:轨道退出/停止时恢复进入前状态;并用于编辑器预览的即时采样。
    /// </summary>
    private Animator _animator;
    
    /// <summary>
    /// 上一帧轨道是否存在任意有效片段(用于检测轨道进入/退出)。
    /// </summary>
    private bool _trackWasActive;
    
    /// <summary>
    /// 是否已记录“进入轨道前”的状态(只记录一次)。
    /// </summary>
    private bool _capturedEnterState;
    
    /// <summary>
    /// 进入轨道前角色的世界坐标位置(用于轨道退出恢复)。
    /// </summary>
    private Vector3 _enterWorldPosition;
    
    /// <summary>
    /// 进入轨道前 Animator 的状态哈希(fullPathHash,用于轨道退出恢复)。
    /// </summary>
    private int _enterStateHash;
    
    /// <summary>
    /// 进入轨道前 Animator 的归一化时间(用于轨道退出恢复到同一帧附近)。
    /// </summary>
    private float _enterNormalizedTime;

    /// <summary>
    /// 进入轨道前 Layer0/Layer1 的权重(用于轨道退出恢复)。
    /// </summary>
    private float _enterLayer0Weight;
    private float _enterLayer1Weight;

    /// <summary>
    /// 进入轨道前 Layer1 的动画状态(用于轨道退出恢复)。
    /// </summary>
    private int _enterLayer1StateHash;
    private float _enterLayer1NormalizedTime;

    /// <summary>
    /// 动画时长缓存:key=stateName,value=该 State 的时长(秒)。
    /// 用途:按动画原速采样时,需要用“动画时长”而不是 “Clip 时长”。
    /// </summary>
    private readonly Dictionary<string, float> _stateLengthSecondsCache = new();

    /// <summary>
    /// Timeline 每帧评估回调:统一进行位置混合与动画采样,避免多个 Clip “抢控制”。
    /// </summary>
    /// <param name="playable">轨道 Mixer 的 Playable(其输入为同轨道上的所有片段)。</param>
    /// <param name="info">帧信息。</param>
    /// <param name="playerData">轨道 Binding 注入对象(本轨道为 Animator)。</param>
    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        // TrackBindingType(typeof(Animator)) 决定 playerData 会是轨道绑定的 Animator
        // 也就是说:你在 Timeline 轨道 Binding 里拖的那个 Animator,会在这里传进来
        var animator = playerData as Animator;
        if (animator == null)
            return;

        // 缓存 animator,供“轨道退出恢复”使用(RestoreEnterStateIfNeeded)
        _animator = animator;

        int inputCount = playable.GetInputCount();

        // 统计总权重,并取出前两个有效片段(用于 Layer0/Layer1 混合)
        // 注意:这里“有效”指 GetInputWeight(i) > 0
        // - 不重叠时:通常只有 1 个有效片段
        // - 重叠时:会同时出现 2 个有效片段
        float totalWeight = 0f;
        int activeCount = 0;
        int activeIndex0 = -1;
        int activeIndex1 = -1;

        for (int i = 0; i < inputCount; i++)
        {
            float inputWeight = playable.GetInputWeight(i);
            if (inputWeight <= 0f)
                continue;

            totalWeight += inputWeight;

            // 只记录前两个有效片段:因为我们用 2 个 Animator Layer 做混合(Layer0/Layer1)
            if (activeCount == 0) activeIndex0 = i;
            else if (activeCount == 1) activeIndex1 = i;

            activeCount++;
        }

        // 没有任何片段生效:本帧不输出
        // 这里用 totalWeight 判断轨道“是否激活”,用于进入/退出检测
        bool trackIsActiveNow = totalWeight > 0f;

        // 轨道退出:从有 -> 无(恢复进入轨道前的位置与动画)
        if (_trackWasActive && !trackIsActiveNow)
        {
            // 轨道整体退出:把角色恢复到进入轨道前的状态(位置 + Layer 权重 + Layer 动画)
            RestoreEnterStateIfNeeded();
            _trackWasActive = false;
            return;
        }

        // 轨道未激活:不输出
        if (!trackIsActiveNow)
            return;

        // 轨道进入:第一次变为有权重时记录进入前状态
        if (!_trackWasActive)
        {
            // 进入轨道:记录当前的位置/动画/Layer 权重,方便之后退出时恢复
            CaptureEnterState(animator);
            _trackWasActive = true;
        }

        // 位置混合:按 Timeline 权重混合(只混合 affectPosition=true 的片段)
        // 说明:位置混合用的是 Timeline 自带权重(可渐入渐出),与动画“固定 0.5/0.5”是两套规则
        if (activeCount > 0)
        {
            float positionWeightSum = 0f;
            Vector3 blended = Vector3.zero;

            for (int i = 0; i < inputCount; i++)
            {
                float w = playable.GetInputWeight(i);
                if (w <= 0f)
                    continue;

                var inputPlayable = (ScriptPlayable<VayneControlMixerBehaviour>)playable.GetInput(i);
                var b = inputPlayable.GetBehaviour();

                // 如果该片段不参与位置混合,就跳过(它只影响动画)
                if (!b.affectPosition)
                    continue;

                blended += b.targetPosition * w;
                positionWeightSum += w;
            }

            if (positionWeightSum > 0f)
            {
                // 权重归一化:把加权和除以权重总和,得到最终输出位置
                blended /= positionWeightSum;
                ApplyPosition(animator.transform, blended);
            }
        }

        // 动画:Animator 多 Layer 混合
        // 关键点:我们不使用 Animator Controller 的参数/BlendTree,而是利用 Layer 权重做 2 段动画的叠加
        if (activeCount <= 0)
            return;

        bool hasSecondLayer = animator.layerCount >= 2;

        if (activeCount == 1 || !hasSecondLayer)
        {
            // 单片段:Layer0=1,Layer1=0
            animator.SetLayerWeight(0, 1f);
            if (hasSecondLayer)
                animator.SetLayerWeight(1, 0f);

            // 把该片段的 stateName 按“动画原速 + 是否循环”的规则采样到 Layer0
            var clipPlayable = (ScriptPlayable<VayneControlMixerBehaviour>)playable.GetInput(activeIndex0);
            var behaviour = clipPlayable.GetBehaviour();
            PlayClipOnLayer(clipPlayable, animator, behaviour, 0);
            animator.Update(0f);
            return;
        }

        // 重叠:固定 0.5/0.5(忽略 Timeline 自带的渐入/渐出权重),取前两个片段做混合
        // 这正是你提出的规则:只要发生重叠,就各 0.5;不重叠则为 1
        animator.SetLayerWeight(0, 0.5f);
        animator.SetLayerWeight(1, 0.5f);

        var clipPlayableA = (ScriptPlayable<VayneControlMixerBehaviour>)playable.GetInput(activeIndex0);
        var clipPlayableB = (ScriptPlayable<VayneControlMixerBehaviour>)playable.GetInput(activeIndex1);
        var behaviourA = clipPlayableA.GetBehaviour();
        var behaviourB = clipPlayableB.GetBehaviour();

        // 约定:第一个有效片段播放到 Layer0,第二个有效片段播放到 Layer1
        PlayClipOnLayer(clipPlayableA, animator, behaviourA, 0);
        PlayClipOnLayer(clipPlayableB, animator, behaviourB, 1);

        // 关键:Update(0f) 立刻刷新到当前采样帧,保证“编辑器所见即所得”
        animator.Update(0f);
    }

    /// <summary>
    /// 图停止回调:Timeline 停止播放时触发;这里做恢复并清理缓存,保证下次播放一致。
    /// </summary>
    /// <param name="playable">图对应的 Playable。</param>
    public override void OnGraphStop(Playable playable)
    {
        // 时间轴停止时清理缓存:确保下次播放能触发一次 Play
        RestoreEnterStateIfNeeded();
        _trackWasActive = false;
        _capturedEnterState = false;
    }

    /// <summary>
    /// 按动画原速采样并播放到指定 Layer(编辑器拖动时间轴也能看到对应帧)。
    /// </summary>
    /// <param name="clipPlayable">片段的 Playable(用于取时间)。</param>
    /// <param name="animator">轨道绑定的 Animator。</param>
    /// <param name="clipConfig">片段配置数据。</param>
    /// <param name="layerIndex">要播放到的 Animator Layer。</param>
    private void PlayClipOnLayer(Playable clipPlayable, Animator animator, VayneControlMixerBehaviour clipConfig, int layerIndex)
    {
        if (string.IsNullOrWhiteSpace(clipConfig.stateName))
            return;

        // 按动画原速采样:用“动画时长”作为分母,而不是 Timeline Clip 时长
        float stateLengthSeconds = GetStateLengthSeconds(animator, layerIndex, clipConfig.stateName);
        if (stateLengthSeconds <= 0.0001f)
            return;

        float clipTimeSeconds = (float)clipPlayable.GetTime();
        float sampleSeconds = clipConfig.loopAnimation
            ? Mathf.Repeat(clipTimeSeconds, stateLengthSeconds)
            : Mathf.Min(clipTimeSeconds, stateLengthSeconds); // 不循环:播一次后停在最后一帧

        float normalized = Mathf.Clamp01(sampleSeconds / stateLengthSeconds);

        // 注意:Update(0f) 在 ProcessFrame 最后统一调用,这里只负责设置播放点
        animator.Play(clipConfig.stateName, layerIndex, normalized);
    }

    /// <summary>
    /// 获取指定 State 的时长(秒)。若未缓存,会先强制采样到该 State 再读取 length。
    /// </summary>
    /// <param name="animator">轨道绑定的 Animator。</param>
    /// <param name="layerIndex">Animator Layer 索引。</param>
    /// <param name="state">Animator State 名称。</param>
    /// <returns>该 State 的时长(秒)。</returns>
    private float GetStateLengthSeconds(Animator animator, int layerIndex, string state)
    {
        if (string.IsNullOrWhiteSpace(state))
            return 0f;

        if (_stateLengthSecondsCache.TryGetValue(state, out float cached) && cached > 0.0001f)
            return cached;

        // 强制切到目标 State 的第 0 帧,再读取 AnimatorStateInfo.length
        animator.Play(state, layerIndex, 0f);
        animator.Update(0f);

        float length = Mathf.Max(0.0001f, animator.GetCurrentAnimatorStateInfo(layerIndex).length);
        _stateLengthSecondsCache[state] = length;
        return length;
    }

    /// <summary>
    /// 输出世界坐标位置到目标 Transform。
    /// </summary>
    /// <param name="targetTransform">要被设置位置的 Transform。</param>
    /// <param name="position">世界坐标位置。</param>
    private static void ApplyPosition(Transform targetTransform, Vector3 position)
    {
        // 输出世界坐标位置
        targetTransform.position = position;
    }

    /// <summary>
    /// 进入轨道时记录“进入前”的位置与动画状态,用于轨道退出时恢复。
    /// </summary>
    /// <param name="animator">轨道绑定的 Animator。</param>
    private void CaptureEnterState(Animator animator)
    {
        // 记录一次进入前状态,供轨道退出时恢复
        if (_capturedEnterState)
            return;

        var tr = animator.transform;
        _enterWorldPosition = tr.position;

        // 记录 Layer0
        _enterLayer0Weight = animator.GetLayerWeight(0);
        var stateInfo0 = animator.GetCurrentAnimatorStateInfo(0);
        _enterStateHash = stateInfo0.fullPathHash;
        _enterNormalizedTime = stateInfo0.normalizedTime;

        // 记录 Layer1(如果存在)
        if (animator.layerCount >= 2)
        {
            _enterLayer1Weight = animator.GetLayerWeight(1);
            var stateInfo1 = animator.GetCurrentAnimatorStateInfo(1);
            _enterLayer1StateHash = stateInfo1.fullPathHash;
            _enterLayer1NormalizedTime = stateInfo1.normalizedTime;
        }
        else
        {
            _enterLayer1Weight = 0f;
            _enterLayer1StateHash = 0;
            _enterLayer1NormalizedTime = 0f;
        }

        _capturedEnterState = true;
    }

    /// <summary>
    /// 如有需要,恢复到进入轨道前的位置与动画状态。
    /// </summary>
    private void RestoreEnterStateIfNeeded()
    {
        if (!_capturedEnterState || _animator == null)
            return;

        var tr = _animator.transform;
        tr.position = _enterWorldPosition;

        // 恢复 Layer 权重
        _animator.SetLayerWeight(0, _enterLayer0Weight);
        if (_animator.layerCount >= 2)
            _animator.SetLayerWeight(1, _enterLayer1Weight);

        if (_enterStateHash != 0)
        {
            _animator.Play(_enterStateHash, 0, _enterNormalizedTime);
        }

        // 恢复 Layer1 动画(如果存在)
        if (_animator.layerCount >= 2 && _enterLayer1StateHash != 0)
        {
            _animator.Play(_enterLayer1StateHash, 1, _enterLayer1NormalizedTime);
        }

        _animator.Update(0f);

        _capturedEnterState = false;
    }
}


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏