8.PlayableGraph的驱动更新模式

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,并设置 DirectorUpdateMode
  • Update():只有 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 的参数配置

  • Vayne1updateMode = GameTime
  • Vayne2updateMode = UnscaledGameTime
  • Vayne3updateMode = DSPClock
    • Optional Audio Clip:建议填一个短音频(最好节拍明显),方便观察 DSPClock 的语义
  • Vayne4updateMode = 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

×

喜欢就点赞,疼爱就打赏