8.PlayableGraph的驱动更新模式
8.1 知识点
DirectorUpdateMode 是什么
PlayableGraph.SetTimeUpdateMode(DirectorUpdateMode mode) 决定了这张 Graph 用哪个时间源推进。
可以这样理解:
Graph 的“推进”就是一次 Evaluate:按当前时间评估整张图,然后把结果输出到 Animator/AudioSource。
差异只在于 Evaluate 由 Unity 自动驱动,还是需要脚本手动调用 PlayableGraph.Evaluate(deltaTime)。
四种驱动模式
1)DSPClock
- 时间源:音频 DSP(Digital Sound Processing)时钟
- 用途:需要和音频严格对齐的 Graph(例如节奏类同步)
- 现象:通常不受
Time.timeScale影响(不走 Time.time)
2)GameTime
- 时间源:
Time.time - 用途:与游戏时间同步(游戏暂停就停)
- 现象:受
Time.timeScale影响,timeScale = 0时会停
3)UnscaledGameTime
- 时间源:
Time.unscaledTime - 用途:UI/菜单/过渡动画等(游戏暂停也要继续)
- 现象:不受
Time.timeScale影响
4)Manual
- 时间源:由业务侧自行决定
- 用途:完全自定义时间推进(例如回放、单步推进、固定 tick)
- 现象:Unity 不会自动推进,必须手动调用
PlayableGraph.Evaluate(deltaTime)
PlayableGraph 的 Evaluate 方法是什么意思
PlayableGraph.Evaluate(deltaTime) 可以拆成两件事:
- 推进时间:把 Graph 的时间往前推
deltaTime秒 - 计算并输出:用“新的时间”评估整张图,并把结果输出到
PlayableOutput(比如 Animator)
所以在 Manual 模式下,如果不调用 Evaluate,即使 graph.Play() 了角色也不会动——因为图没有被评估。可以把它理解成 Graph 的一次 Tick。
思路和步骤
这一节用 4 个 VN(Vayne1~Vayne4)做横向对比:**同一脚本、同一动画,只改 updateMode**。进入 Play 后差异会很直观。
四种模式的差异主要体现在:
GameTime:跟着Time.time走;当timeScale=0时会停住UnscaledGameTime:走Time.unscaledTime,游戏暂停它也照样播DSPClock:走音频 DSP 时钟,适合那种“必须和音频对齐”的场景Manual:完全由手动推进,不Evaluate就不会动
代码实现
这一节只用 一个脚本:PlayableGraphUpdateModeSample.cs。关键点主要在两处:
Start():搭 Graph,并设置DirectorUpdateModeUpdate():只有Manual模式需要主动Evaluate()推进时间
下面按脚本的实际结构拆开看。
1)Start:搭图(四种模式共用)
先搭一个最小可用的动画图:创建 Graph、设置时间驱动模式、创建动画输出与 Clip 节点、连线并 Play()。
// 1) 创建 Graph,并设置时间驱动模式(四种模式的入口就在这一行)
playableGraph = PlayableGraph.Create($"Lesson08_UpdateMode_{updateMode}");
playableGraph.SetTimeUpdateMode(updateMode);
// 2) 输出 -> Animator
AnimationPlayableOutput animationPlayableOutput =
AnimationPlayableOutput.Create(playableGraph, "Animation", animator);
// 3) 节点 -> AnimationClipPlayable
animationClipPlayable = AnimationClipPlayable.Create(playableGraph, targetAnimationClip);
animationPlayableOutput.SetSourcePlayable(animationClipPlayable);
// 4) 切到 Playing 状态(注意:Manual 仍然不会自动“走时间”)
playableGraph.Play();
到这里图已经能输出动画了,剩下的差异只在于:时间由谁来推进。
2)GameTime / UnscaledGameTime / DSPClock:时间由 Unity 自动推进
这三种模式下,脚本不需要在 Update() 里主动 Evaluate()。SetTimeUpdateMode(...) 之后,Unity 会用对应时间源推进并评估 Graph。
GameTime:Unity 使用Time.time推进(受Time.timeScale影响)UnscaledGameTime:Unity 使用Time.unscaledTime推进(不受Time.timeScale影响)DSPClock:Unity 使用音频 DSP 时钟推进(语义上更偏向音频同步)
因此实现上没有额外分支:除了设置 updateMode,不需要手动干预。
3)DSPClock:额外补一条音频输出(用于把语义讲清楚)
只播动画时,DSPClock 的差异不太直观,所以脚本里加了可选分支:当 DSPClock + optionalAudioClip != null 时,把音频也接进同一张 Graph。
if (updateMode == DirectorUpdateMode.DSPClock && optionalAudioClip != null)
{
AudioSource audioSource = GetComponent<AudioSource>();
if (audioSource == null)
audioSource = gameObject.AddComponent<AudioSource>();
AudioPlayableOutput audioPlayableOutput =
AudioPlayableOutput.Create(playableGraph, "Audio", audioSource);
AudioClipPlayable audioClipPlayable =
AudioClipPlayable.Create(playableGraph, optionalAudioClip, loopAudio);
audioPlayableOutput.SetSourcePlayable(audioClipPlayable);
}
这段逻辑的重点不是“多播一条音频”,而是把语义落到画面:同一张 Graph 里,动画与音频都由 DSPClock 推进。
4)Update:按键与 Manual 推进(脚本负责“喂时间”)
Update() 里有两类逻辑:按键(便于录制和对齐日志)以及 Manual 模式的推进。
- 按键与日志(可选):用于录制时对齐“按了什么/发生了什么”
T:切Time.timeScale = 1 / 0(用来拉开GameTime与其他模式的差异)R:把AnimationClipPlayable的时间重置到 0;如果是 Manual,会补一次Evaluate(0)立即刷新输出
- Manual 的推进:只有
updateMode == Manual时才执行Space:开/关“每帧自动 Evaluate”N:单步Evaluate(singleStepDeltaSeconds)(用于观察“推进一次”的效果)- 自动驱动开启时:每帧执行
Evaluate(Time.deltaTime * manualDriveSpeed)
代码里对 Manual 的判断很直接:不是 Manual 就 return;只有 Manual 才进入推进逻辑。
if (updateMode != DirectorUpdateMode.Manual)
return;
// 单步推进:每按一次推进一次
if (enableHotkeys && Input.GetKeyDown(KeyCode.N))
playableGraph.Evaluate(singleStepDeltaSeconds);
// 自动推进:每帧推进 dt
if (isAutoManualDriveEnabled)
{
float dt = Time.deltaTime * manualDriveSpeed;
playableGraph.Evaluate(dt);
}
这里的关键点一句话:**Manual 模式下时间不是 Unity 自动推进,而是脚本显式调用 PlayableGraph.Evaluate(deltaTime)**。
5)OnDisable:销毁图,避免资源泄漏
PlayableGraph 需要显式释放,脚本在 OnDisable() 里收尾:
if (playableGraph.IsValid())
playableGraph.Destroy();
场景准备
1)准备 4 个 VN
- Animator.Controller:保持
None(不要挂 AnimatorController) - 脚本:四个 VN 都挂
PlayableGraphUpdateModeSample - Target Animation Clip:四个 VN 填同一个(比如
Vayne_run)
2)四个 VN 的参数配置
- Vayne1:
updateMode = GameTime - Vayne2:
updateMode = UnscaledGameTime - Vayne3:
updateMode = DSPClockOptional Audio Clip:建议填一个短音频(最好节拍明显),方便观察 DSPClock 的语义
- Vayne4:
updateMode = Manual
3)按键只开一个(避免重复响应)
四个 VN 里只开 一个 Enable Hotkeys(示例里开在 Vayne4)。
效果展示
按
T,Console 会打印Time.timeScale=0/1;画面上 Vayne1 停住,Vayne2/Vayne3 继续。先
Space关掉 Manual 自动驱动,再连续按N:角色一格一格推进,Console 同步打印dt=...,可以直观看到 “Evaluate = 推进一次”。

8.2 知识点代码
PlayableGraphUpdateModeSample.cs
using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Audio;
using UnityEngine.Playables;
/// <summary>
/// PlayableGraph 的驱动更新模式(DirectorUpdateMode)示例
///
/// 可以把 DirectorUpdateMode 理解成:这张 Graph “用哪一种时间源” 来推进。
/// Graph 的“推进”就是:每一帧(或每次手动触发)评估图(Evaluate),计算当前时间点应该输出的动画/音频。
///
/// 四种模式:
/// - DSPClock:用音频 DSP 时钟推进(更适合与音频严格同步)
/// - GameTime:用 Time.time 推进(受 Time.timeScale 影响,暂停游戏时会停)
/// - UnscaledGameTime:用 Time.unscaledTime 推进(不受 timeScale 影响,游戏暂停也能继续)
/// - Manual:完全手动推进(必须手动调用 PlayableGraph.Evaluate(deltaTime))
///
/// 建议用法(符合当前的 Lesson 组织方式):
/// - 复制 4 个 VN(Vayne1~Vayne4),全部挂本脚本
/// - 分别把 updateMode 设为:DSPClock / GameTime / UnscaledGameTime / Manual
/// - Play 后把 Time.timeScale 改成 0:现象是 GameTime 停住,Unscaled/DSPClock 继续
/// - Manual 则只有手动 Evaluate 才会动(按键见下)
///
/// 按键(只建议在其中一个对象上开启 enableHotkeys=true,避免多脚本抢输入):
/// - T:切换 Time.timeScale = 1 / 0(用于验证 GameTime vs Unscaled / DSPClock)
/// - Space:Manual 模式下,暂停/继续“自动手动驱动”
/// - N:Manual 模式下,单步推进 singleStepDeltaSeconds
/// - R:把动画时间回到 0(Manual 会立即刷新一次输出)
/// </summary>
[RequireComponent(typeof(Animator))]
public class PlayableGraphUpdateModeSample : MonoBehaviour
{
private const string LogTag = "[PlayableGraphUpdateModeSample]";
[Header("基础配置")] [Tooltip("这张 Graph 用哪一种时间源来推进。")]
public DirectorUpdateMode updateMode = DirectorUpdateMode.GameTime;
[Tooltip("要播放的动画剪辑(VN 的动作)。")] public AnimationClip targetAnimationClip;
[Header("DSPClock(可选)")] [Tooltip("仅用于 DSPClock 演示:如果填了音频,会额外创建 AudioPlayableOutput,方便理解“与音频同步”。")]
public AudioClip optionalAudioClip;
[Tooltip("音频是否循环(仅 optionalAudioClip 有值时生效)。")]
public bool loopAudio = true;
[Header("Manual(手动推进)")] [Tooltip("Manual 模式下的速度倍率。1=正常,0=停。")] [Range(0f, 5f)]
public float manualDriveSpeed = 1f;
[Tooltip("Manual 模式单步推进的时间(秒)。")] [Range(0.001f, 0.2f)]
public float singleStepDeltaSeconds = 1f / 30f;
[Header("输入(建议只开一个)")] [Tooltip("是否启用按键控制。场景里如果有多个对象挂了该脚本,只建议开一个,避免重复响应输入。")]
public bool enableHotkeys = true;
private Animator animator;
private PlayableGraph playableGraph;
private AnimationClipPlayable animationClipPlayable;
// Manual 模式下:是否每帧自动手动驱动(true=每帧 Evaluate)
private bool isAutoManualDriveEnabled = true;
void Start()
{
animator = GetComponent<Animator>();
// 1. 创建 Graph
playableGraph = PlayableGraph.Create($"Lesson08_UpdateMode_{updateMode}");
// 2. 设置 Graph 的时间驱动模式(核心点就在这里)
playableGraph.SetTimeUpdateMode(updateMode);
// 3. 动画输出 -> Animator
AnimationPlayableOutput animationPlayableOutput =
AnimationPlayableOutput.Create(playableGraph, "Animation", animator);
animationClipPlayable = AnimationClipPlayable.Create(playableGraph, targetAnimationClip);
animationPlayableOutput.SetSourcePlayable(animationClipPlayable);
// 4. 可选:DSPClock 模式下加一条音频输出(更符合 “DSPClock 为音频服务” 的语义)
if (updateMode == DirectorUpdateMode.DSPClock && optionalAudioClip != null)
{
// AudioSource 不存在就动态加一个(仅用于演示)
AudioSource audioSource = GetComponent<AudioSource>();
if (audioSource == null)
{
audioSource = gameObject.AddComponent<AudioSource>();
Debug.Log($"{LogTag} {name} DSPClock:未找到 AudioSource,已自动 AddComponent<AudioSource>()。");
}
AudioPlayableOutput audioPlayableOutput =
AudioPlayableOutput.Create(playableGraph, "Audio", audioSource);
AudioClipPlayable audioClipPlayable =
AudioClipPlayable.Create(playableGraph, optionalAudioClip, loopAudio);
audioPlayableOutput.SetSourcePlayable(audioClipPlayable);
Debug.Log(
$"{LogTag} {name} DSPClock:已创建音频输出,clip={optionalAudioClip.name},loop={loopAudio}。");
}
// 5. Play():把图切到 Playing 状态
// 注意:Manual 模式下 Play() 并不会让时间自动前进,依然需要手动 Evaluate 才会动
playableGraph.Play();
Debug.Log(
$"{LogTag} {name} Start:updateMode={updateMode},clip={(targetAnimationClip != null ? targetAnimationClip.name : "null")},enableHotkeys={enableHotkeys}。");
}
void Update()
{
if (!playableGraph.IsValid())
return;
if (enableHotkeys && Input.GetKeyDown(KeyCode.T))
{
// 用来验证 GameTime vs Unscaled/DSPClock
Time.timeScale = Mathf.Approximately(Time.timeScale, 0f) ? 1f : 0f;
Debug.Log($"{LogTag} {name} 按下 T:Time.timeScale={Time.timeScale}。");
}
if (enableHotkeys && Input.GetKeyDown(KeyCode.R))
{
// 重置“时间指针”
animationClipPlayable.SetTime(0d);
// Manual 下如果不 Evaluate,就不会立刻刷新输出,所以这里补一次 Evaluate(0)
if (updateMode == DirectorUpdateMode.Manual)
playableGraph.Evaluate(0f);
Debug.Log($"{LogTag} {name} 按下 R:重置到动画起点(time=0)。");
}
// 只有 Manual 模式才需要手动 Evaluate
if (updateMode != DirectorUpdateMode.Manual)
return;
if (enableHotkeys && Input.GetKeyDown(KeyCode.Space))
{
isAutoManualDriveEnabled = !isAutoManualDriveEnabled;
Debug.Log($"{LogTag} {name} 按下 Space:Manual 自动驱动={isAutoManualDriveEnabled}。");
}
if (enableHotkeys && Input.GetKeyDown(KeyCode.N))
{
// 单步推进:Evaluate 一次,就推进一次
playableGraph.Evaluate(singleStepDeltaSeconds);
Debug.Log($"{LogTag} {name} 按下 N:Manual 单步推进 dt={singleStepDeltaSeconds:F4}s。");
}
if (isAutoManualDriveEnabled)
{
// 这句就是 Manual 的核心:每帧推进多少时间,图就前进多少
float dt = Time.deltaTime * manualDriveSpeed;
playableGraph.Evaluate(dt);
}
}
void OnDisable()
{
if (playableGraph.IsValid())
playableGraph.Destroy();
}
}
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com