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/Out、Volume(以及 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:离开片段后恢复进入片段前的激活状态(最省心,避免把场景留脏)。
- Source Game Object:控制场景里已有的对象(图里是
- Advanced
- Control Playable Directors:接管目标对象(及其子物体)上的
PlayableDirector。 - Control Particle Systems:接管粒子系统(播放/停止/模拟)。
- Random Seed:粒子随机种子;固定后预览更稳定。
- Control ITimeControl:接管实现了
ITimeControl的组件(更偏自定义时间驱动接口)。 - Control Children:把控制扩散到子物体。
- Control Playable Directors:接管目标对象(及其子物体)上的
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:决定“发出的信号是哪一个资产”。
- Project 里的一个资源(
- Signal Emitter(什么时候发)
- 时间轴上的一个 Marker(一个小标记点)。
- 它持有
SignalAsset的引用,并在时间线评估到该时刻时触发一次“发信号”。
- SignalReceiver(发给谁)
- Signal Track 左侧的 Binding 槽绑定的对象上,需要有
SignalReceiver组件。 - Timeline 只负责把信号送到这个 Receiver,至于“收到以后做什么”,不在 Track 里存。
- Signal Track 左侧的 Binding 槽绑定的对象上,需要有
- Reaction(收到后做什么)
- Reaction 配置是存放在
SignalReceiver组件里的:本质是一张表SignalAsset -> UnityEvent。 - 时间到时,Receiver 根据 SignalAsset 找到对应的 UnityEvent,然后 Invoke。
- Reaction 配置是存放在


上面两张图对应的就是这条链路在编辑器里的落点:
- 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 资源(图里是
Signal Receiver / Reaction
- Receiver Component on
- 事件会发给哪个对象上的
SignalReceiver(图里是SignalReceiver)。
- 事件会发给哪个对象上的
- Reaction
- Signal -> 要调用的函数。这里选的是
SignalReceiverTest.OnReceiveSignal()。 - 右侧的
Runtime Only表示只在运行时触发(编辑器预览下是否触发取决于 Timeline 的播放方式)。
- Signal -> 要调用的函数。这里选的是
运行时替换 Receiver:Binding 只解决“发给谁”,Reaction 还要解决“做什么”
常见踩坑点是:把 Signal Track 的绑定对象从 SignalReceiver 换成 SignalReceiver2 后,Emitter 还在,但回调不触发。
原因并不复杂:Reaction 是存放在 SignalReceiver 组件上的数据。
- 绑定换过去以后,Signal 当然会“送到”新的 Receiver。
- 但如果
SignalReceiver2上的SignalReceiver组件里,没有配置同一个SignalAsset对应的 Reaction,那么 Receiver 收到信号也不知道要调用哪个函数。
稳定做法一般就两种:
- 编辑器配好:在
SignalReceiver2的SignalReceiver组件里,也为同一个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里按脚本逻辑每帧更新位置。
- Game Obj:要控制的目标对象(例子里填的是
自定义轨道
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/需要绑定什么对象”进行声明
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。
- 现象:Inspector 里拖场景对象会报
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 混动画、或只取主导片段等),行为可控、可解释。
CreateTrackMixer 和 ClipCaps.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