7.Unity进阶Playable自定义Playable

7.Playable自定义Playable


7.1 知识点

PlayableGraph、IPlayable、Playable、ScriptPlayable、PlayableBehaviour 之间的关系

  • PlayableGraph
    图的容器,管理所有节点和输出。用 PlayableGraph.Create() 创建,负责图的生命周期。

  • PlayableOutput
    图的输出节点,由 PlayableGraph 创建并绑定到具体目标(如 AnimatorAudioSource),用来把计算结果写回场景。

  • IPlayable
    播放节点的统一接口。Playable 与各种具体 Playable 都是基于它来对外工作。

  • Playable
    一个轻量 struct,内部是 PlayableHandle 的封装,实现 IPlayable。常被当作通用节点类型使用。

  • ScriptPlayable
    实现 IPlayable 的泛型 struct,用来包装 PlayableBehaviour。它与 Playable 之间有显式/隐式转换,因此可以当作普通节点连接到图中。

  • PlayableBehaviour
    业务逻辑基类,包含 PrepareFrame()OnPlayableCreate() 等回调方法。

关系总结

  • PlayableGraph 管理所有节点与输出
  • PlayableOutputPlayableGraph 创建并受其管理,图的“出口”通过它把结果输出到目标
  • Playable / ScriptPlayable<T> 都实现 IPlayable
  • ScriptPlayable<T> 内部持有 PlayableBehaviour
  • 业务逻辑通过 ScriptPlayable<T>.GetBehaviour() 取得
  • PrepareFrame() 等回调里会传入 owner(包装它的 Playable 节点)
classDiagram
    %% 图容器 / 输出
    PlayableGraph "1" o-- "many" Playable : 管理/包含节点
    PlayableGraph "1" o-- "many" PlayableOutput : 管理/包含输出
    PlayableOutput --> Playable : SetSourcePlayable() 绑定源节点

    %% 接口与实现
    IPlayable <|.. Playable
    IPlayable <|.. ScriptPlayable~T~
    IPlayable <|.. AnimationClipPlayable
    IPlayable <|.. AnimationMixerPlayable
    IPlayable <|.. AudioClipPlayable

    %% 行为逻辑(节点包装 Behaviour)
    ScriptPlayable~T~ --> PlayableBehaviour : 包装/持有
    PlayableBehaviour <|-- UserDefinedBehaviour

    %% 关键接口(节选,方便对照)
    class PlayableGraph {
        +Create() 创建图
        +Play() 播放
        +Stop() 停止
        +Destroy() 销毁/释放
        +Connect() 连接节点
        +Evaluate() 手动评估
    }
    class Playable {
        <> 实现 IPlayable
        +GetGraph() 获取所属 Graph
    }
    class ScriptPlayable~T~ {
        <>
        +GetBehaviour() 获取 Behaviour 实例
    }
    class PlayableBehaviour {
        +OnPlayableCreate() 创建回调
        +PrepareFrame() 帧前准备
        +ProcessFrame() 帧处理
        +OnPlayableDestroy() 销毁回调
    }
    class PlayableOutput {
        <> 实现 IPlayableOutput
        +SetSourcePlayable(p : Playable) 指定输出来源
    }
    class UserDefinedBehaviour {
        <<继承 PlayableBehaviour>>
    }

自定义Playable实现目标

本例的目标是做一个“动画队列”:传入 AnimationClip[],按顺序播放,单个 clip 播完自动切到下一个,并循环回到第一个。

思路和步骤

实现思路

  • PlayableBehaviour 里控制切换逻辑,用 ScriptPlayable<T> 变成节点,再接入图并输出

核心步骤

  • 实现自定义 PlayableBehaviour(继承 PlayableBehaviour,实现 Initialize()PrepareFrame()
  • 在 MonoBehaviour 中使用(创建 ScriptPlayable 节点,获取 PlayableBehaviour 实例初始化并连接输出)

代码实现

步骤1:实现自定义 PlayableBehaviour

1.1 继承 PlayableBehaviour 并定义字段

public class PlayQueueClipPlayableBehaviour : PlayableBehaviour
{
    private int currentClipIndex = -1;
    private float timeToNextClip;
    private AnimationMixerPlayable animationMixerPlayable;
}

继承 PlayableBehaviour,定义需要的字段:currentClipIndex 记录当前播放索引,timeToNextClip 记录到下一个剪辑的剩余时间,animationMixerPlayable 是内部混合器。

1.2 实现 Initialize 方法

public void Initialize(AnimationClip[] animationClipArray, Playable ownerScriptPlayable, PlayableGraph playableGraph)
{
    // 设置 ownerScriptPlayable 节点的输入数量为 1
    ownerScriptPlayable.SetInputCount(1);

    // 创建混合器,输入数量等于动画剪辑数量
    animationMixerPlayable = AnimationMixerPlayable.Create(playableGraph, animationClipArray.Length);

    // 将混合器连接到 ownerScriptPlayable 节点的输入端口
    playableGraph.Connect(animationMixerPlayable, 0, ownerScriptPlayable, 0);

    // 设置 ownerScriptPlayable 节点的输入权重为 1
    ownerScriptPlayable.SetInputWeight(0, 1f);

    // 为每个动画剪辑创建可播放项并连接到混合器
    for (int clipIndex = 0; clipIndex < animationMixerPlayable.GetInputCount(); clipIndex++)
    {
        playableGraph.Connect(AnimationClipPlayable.Create(playableGraph, animationClipArray[clipIndex]), 0, animationMixerPlayable, clipIndex);
        animationMixerPlayable.SetInputWeight(clipIndex, 0.0f);
    }

    // 初始化第一个剪辑的状态
    if (animationMixerPlayable.GetInputCount() > 0)
    {
        currentClipIndex = 0;
        AnimationClipPlayable firstClip = (AnimationClipPlayable)animationMixerPlayable.GetInput(0);
        firstClip.SetTime(0f);
        timeToNextClip = firstClip.GetAnimationClip().length;
        animationMixerPlayable.SetInputWeight(0, 1.0f);
    }
}

这个 Initialize() 主要做四件事:给 ScriptPlayable 开输入端口、创建 mixer、把每个 clip 接到 mixer、初始化第一个要播的 clip。

这里需要传入 ownerScriptPlayablePlayableBehaviour 本身拿不到外层节点,但初始化阶段要连线/设端口,所以必须由外部传入。

1.3 重载 PrepareFrame 方法

public override void PrepareFrame(Playable owner, FrameData info)
{
    // 如果没有输入,直接返回
    if (animationMixerPlayable.GetInputCount() == 0)
        return;

    // 减少距离下一个剪辑的时间
    timeToNextClip -= (float)info.deltaTime;

    // 如果当前剪辑播放完毕,切换到下一个剪辑
    if (timeToNextClip <= 0.0f)
    {
        // 移动到下一个剪辑索引
        currentClipIndex++;

        // 如果已经播放完所有剪辑,循环回到第一个
        if (currentClipIndex >= animationMixerPlayable.GetInputCount())
            currentClipIndex = 0;

        // 获取当前剪辑的可播放项
        AnimationClipPlayable currentClip = (AnimationClipPlayable)animationMixerPlayable.GetInput(currentClipIndex);

        // 重置时间,使下一个剪辑从开头开始播放
        currentClip.SetTime(0f);

        // 设置距离下一个剪辑的时间为当前剪辑的长度
        timeToNextClip = currentClip.GetAnimationClip().length;
    }

    // 调整所有输入权重:当前播放的剪辑权重为 1.0,其他为 0.0
    for (int clipIndex = 0; clipIndex < animationMixerPlayable.GetInputCount(); clipIndex++)
    {
        if (clipIndex == currentClipIndex)
            animationMixerPlayable.SetInputWeight(clipIndex, 1.0f);
        else
            animationMixerPlayable.SetInputWeight(clipIndex, 0.0f);
    }
}

PrepareFrame() 每帧都会被 Unity 调用,这里是业务逻辑入口。这个例子里:当前 clip 播完就切到下一个,并把 mixer 的权重切到当前那一路。

步骤2:在 MonoBehaviour 中使用自定义 PlayableBehaviour

2.1 创建图并设置更新模式

// 创建 Playable 图并设置更新模式为 GameTime
playableGraph = PlayableGraph.Create();
playableGraph.SetTimeUpdateMode(DirectorUpdateMode.GameTime);

先创建 PlayableGraph,并设置时间更新模式为游戏时间。

2.2 创建 ScriptPlayable 节点

// 使用 ScriptPlayable 创建自定义的 PlayQueueClipPlayableBehaviour
ScriptPlayable<PlayQueueClipPlayableBehaviour> playQueueScriptPlayable = 
    ScriptPlayable<PlayQueueClipPlayableBehaviour>.Create(playableGraph);

ScriptPlayable<T>.Create() 创建节点,T 填自定义 PlayableBehaviour 类型。ScriptPlayable<T> 可隐式转成 Playable,后续连线可以当普通节点用。

2.3 获取 PlayableBehaviour 实例并初始化

// 获取 PlayableBehaviour 实例
PlayQueueClipPlayableBehaviour playQueueClipPlayableBehaviour = playQueueScriptPlayable.GetBehaviour();

// 初始化动画队列
playQueueClipPlayableBehaviour.Initialize(animationClipArray, playQueueScriptPlayable, playableGraph);

GetBehaviour() 拿到 PlayableBehaviour 实例,再调用初始化方法。注意初始化时要把 ScriptPlayable 节点本身传进去,因为 PlayableBehaviour 自己拿不到“外壳节点”。

2.4 创建动画输出并连接到 ScriptPlayable 节点

// 创建动画输出,连接到 Animator 组件
AnimationPlayableOutput animationPlayableOutput =
    AnimationPlayableOutput.Create(playableGraph, "Animation", GetComponent<Animator>());

// 设置输出源为自定义的 PlayQueueClipPlayableBehaviour
animationPlayableOutput.SetSourcePlayable(playQueueScriptPlayable);

创建动画输出节点,绑定到 Animator。然后把 ScriptPlayable 节点设为输出源。这里传的是节点,不是节点内部的 Behaviour 对象。

2.5 播放图

// 播放该图
playableGraph.Play();

开始播放图,之后每帧 Unity 会自动更新这张图,PlayableBehaviourPrepareFrame() 也会被调用。

2.6 清理资源

void OnDisable()
{
    // 销毁该图创建的所有可播放项和输出
    playableGraph.Destroy();
}

组件禁用时调用 Destroy() 释放资源(节点与输出),避免内存泄漏。建议放在 OnDisable(),组件禁用时即可释放。

效果展示




可以看到 idle / run / attack 会循环播放:播完一个就切到下一个。


7.2 知识点代码

PlayQueueSample.cs

using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;

/// <summary>
/// 使用自定义 PlayableBehaviour 的示例组件
/// 该组件演示了如何使用 ScriptPlayable 创建和使用自定义的 PlayableBehaviour
/// </summary>
[RequireComponent(typeof(Animator))]
public class PlayQueueSample : MonoBehaviour
{
    /// <summary>
    /// 要按顺序播放的动画剪辑数组
    /// </summary>
    public AnimationClip[] animationClipArray;

    /// <summary>
    /// Playable 图,用于管理动画播放流程
    /// </summary>
    PlayableGraph playableGraph;

    /// <summary>
    /// 初始化并创建动画队列
    /// </summary>
    void Start()
    {
        // 创建 Playable 图并设置更新模式为 GameTime
        playableGraph = PlayableGraph.Create();
        playableGraph.SetTimeUpdateMode(DirectorUpdateMode.GameTime);

        // 使用 ScriptPlayable 创建自定义的 PlayQueueClipPlayableBehaviour
        // playQueueScriptPlayable 是 PlayableGraph 中的节点(ScriptPlayable<PlayQueueClipPlayableBehaviour> 类型)
        // ScriptPlayable<T> 是 Playable 的一个具体实现,它包装了 PlayableBehaviour
        // ScriptPlayable<T> 可以隐式转换为 Playable,所以可以当作 Playable 使用
        // 它包装了 PlayQueueClipPlayableBehaviour 行为,是图中的一个可连接节点
        ScriptPlayable<PlayQueueClipPlayableBehaviour> playQueueScriptPlayable = ScriptPlayable<PlayQueueClipPlayableBehaviour>.Create(playableGraph);

        // 获取 PlayableBehaviour 实例
        // playQueueClipPlayableBehaviour 是实际的业务逻辑对象(PlayableBehaviour 类型)
        // 它包含 Initialize、PrepareFrame 等方法,负责动画队列的具体实现
        // 注意:PlayableBehaviour 无法直接获取包装它的 ScriptPlayable
        // 虽然在 PrepareFrame 等方法中会传入 owner 参数(就是包装它的 Playable),
        // 但 Initialize 是在外部调用的,此时还没有进入 Playable 的生命周期,所以需要显式传入
        PlayQueueClipPlayableBehaviour playQueueClipPlayableBehaviour = playQueueScriptPlayable.GetBehaviour();

        // 初始化动画队列
        // ownerScriptPlayable 参数传入 playQueueScriptPlayable,表示"拥有此行为的 ScriptPlayable 节点"
        // 参数类型是 Playable,但传入的是 ScriptPlayable<PlayQueueClipPlayableBehaviour>
        // 这是因为 ScriptPlayable<T> 可以隐式转换为 Playable(通过隐式转换操作符)
        // 在 Initialize 中需要操作 Playable 节点(如设置输入数量、连接其他节点),所以需要传入节点本身
        playQueueClipPlayableBehaviour.Initialize(animationClipArray, playQueueScriptPlayable, playableGraph);

        // 创建动画输出,连接到 Animator 组件
        AnimationPlayableOutput animationPlayableOutput =
            AnimationPlayableOutput.Create(playableGraph, "Animation", GetComponent<Animator>());

        // 设置输出源为自定义的 PlayQueueClipPlayableBehaviour
        // 注意:这里传入的是 playQueueScriptPlayable(Playable 节点),而不是 playQueueClipPlayableBehaviour(Behaviour 行为)
        // 因为输出需要连接到图中的一个节点,而不是节点内部的行为
        // SetSourcePlayable 接受 Playable 类型参数,ScriptPlayable<T> 可以隐式转换
        animationPlayableOutput.SetSourcePlayable(playQueueScriptPlayable);

        // 播放该图
        playableGraph.Play();
    }

    /// <summary>
    /// 组件禁用时清理资源,销毁 Playable 图及其所有可播放项和输出
    /// </summary>
    void OnDisable()
    {
        // 销毁该图创建的所有可播放项和输出
        playableGraph.Destroy();
    }
}

PlayQueueClipPlayableBehaviour.cs

using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;

/// <summary>
/// 自定义 PlayableBehaviour,用于按顺序播放多个动画剪辑
/// 该组件演示了如何创建自定义的可播放项,通过重载 PrepareFrame() 方法控制动画播放顺序
/// 一次只播放一个动画剪辑,当前剪辑播放完毕后自动切换到下一个
/// </summary>
public class PlayQueueClipPlayableBehaviour : PlayableBehaviour
{
    /// <summary>
    /// 当前播放的动画剪辑索引
    /// </summary>
    private int currentClipIndex = -1;

    /// <summary>
    /// 距离下一个剪辑的时间
    /// </summary>
    private float timeToNextClip;

    /// <summary>
    /// 动画混合器,用于混合多个动画剪辑
    /// </summary>
    private AnimationMixerPlayable animationMixerPlayable;

    /// <summary>
    /// 初始化动画队列
    /// </summary>
    /// <param name="animationClipArray">要播放的动画剪辑数组</param>
    /// <param name="ownerScriptPlayable">拥有此行为的 ScriptPlayable 节点(即 ScriptPlayable&lt;PlayQueueClipPlayableBehaviour&gt;)</param>
    /// <param name="playableGraph">PlayableGraph</param>
    /// 
    /// 【Playable 和 ScriptPlayable 的关系说明】
    /// - Playable 是一个结构体,是所有 Playable 类型的基类/接口
    /// - ScriptPlayable<T> 是 Playable 的一个具体实现,它包装了一个 PlayableBehaviour
    /// - ScriptPlayable<T> 可以隐式转换为 Playable(通过隐式转换操作符)
    /// - 所以参数类型是 Playable,但实际传入的是 ScriptPlayable<PlayQueueClipPlayableBehaviour>
    /// 
    /// 【为什么需要显式传入 ownerScriptPlayable】
    /// - PlayableBehaviour 无法直接获取包装它的 ScriptPlayable
    /// - 虽然在 PrepareFrame、OnPlayableCreate 等方法中会传入 owner 参数(就是包装它的 Playable),
    ///   但 Initialize 是在外部调用的,此时还没有进入 Playable 的生命周期
    /// - 因此需要在外部获取 ScriptPlayable 后,显式传入给 Initialize 方法
    /// 
    /// 【ownerScriptPlayable 和当前 PlayableBehaviour 实例的关系】
    /// - ownerScriptPlayable 是 PlayableGraph 中的节点(容器),类型是 ScriptPlayable<PlayQueueClipPlayableBehaviour>
    /// - 当前对象(this)是这个节点包含的行为(业务逻辑),类型是 PlayQueueClipPlayableBehaviour
    /// - 通过 ownerScriptPlayable 可以操作节点在图中的连接和配置(如设置输入数量、连接其他节点)
    /// - 在 PrepareFrame 等方法中,Unity 会自动传入 owner 参数,就是同一个 ownerScriptPlayable</param>
    public void Initialize(AnimationClip[] animationClipArray, Playable ownerScriptPlayable, PlayableGraph playableGraph)
    {
        // 设置 ownerScriptPlayable 节点的输入数量为 1
        // ownerScriptPlayable 是 PlayableGraph 中的节点,需要设置它有多少个输入端口
        ownerScriptPlayable.SetInputCount(1);

        // 创建混合器,输入数量等于动画剪辑数量
        animationMixerPlayable = AnimationMixerPlayable.Create(playableGraph, animationClipArray.Length);

        // 将混合器连接到 ownerScriptPlayable 节点的输入端口
        // Connect 参数说明:
        // 参数1:源 Playable(animationMixerPlayable - 混合器)
        // 参数2:源输出端口(0 - 混合器只有一个输出端口)
        // 参数3:目标 Playable(ownerScriptPlayable - 拥有此行为的 ScriptPlayable 节点)
        // 参数4:目标输入端口(0 - ownerScriptPlayable 的第一个输入端口)
        // 这样混合器的输出会连接到 ownerScriptPlayable 节点,ownerScriptPlayable 再输出到图的外部
        playableGraph.Connect(animationMixerPlayable, 0, ownerScriptPlayable, 0);

        // 设置 ownerScriptPlayable 节点的输入权重为 1
        // 表示混合器的输出完全传递给 ownerScriptPlayable 节点
        ownerScriptPlayable.SetInputWeight(0, 1f);

        // 为每个动画剪辑创建可播放项并连接到混合器
        for (int clipIndex = 0; clipIndex < animationMixerPlayable.GetInputCount(); clipIndex++)
        {
            // 创建动画剪辑的可播放项并连接到混合器
            playableGraph.Connect(AnimationClipPlayable.Create(playableGraph, animationClipArray[clipIndex]), 0, animationMixerPlayable, clipIndex);
            // 设置初始权重为 0.0(PrepareFrame 会调整第一个剪辑的权重为 1.0)
            animationMixerPlayable.SetInputWeight(clipIndex, 0.0f);
        }

        // 初始化第一个剪辑的状态
        if (animationMixerPlayable.GetInputCount() > 0)
        {
            currentClipIndex = 0;
            AnimationClipPlayable firstClip = (AnimationClipPlayable)animationMixerPlayable.GetInput(0);
            firstClip.SetTime(0f);
            timeToNextClip = firstClip.GetAnimationClip().length;
            animationMixerPlayable.SetInputWeight(0, 1.0f);
        }
    }

    /// <summary>
    /// 重载 PrepareFrame 方法,每帧调用以更新播放状态
    /// 控制动画剪辑的切换和权重调整
    /// </summary>
    /// <param name="owner">拥有此行为的 Playable 节点
    /// 注意:这个 owner 参数就是 Initialize 时传入的 ownerScriptPlayable(同一个对象)
    /// Unity 会在每帧自动调用此方法,并传入包装此 PlayableBehaviour 的 Playable 节点
    /// 如果需要在 PrepareFrame 中操作节点,可以使用这个 owner 参数,而不需要保存 Initialize 时的 ownerScriptPlayable</param>
    /// <param name="info">帧数据,包含时间信息</param>
    public override void PrepareFrame(Playable owner, FrameData info)
    {
        // 如果没有输入,直接返回
        if (animationMixerPlayable.GetInputCount() == 0)
            return;

        // 减少距离下一个剪辑的时间
        timeToNextClip -= (float)info.deltaTime;

        // 如果当前剪辑播放完毕,切换到下一个剪辑
        if (timeToNextClip <= 0.0f)
        {
            // 移动到下一个剪辑索引
            currentClipIndex++;

            // 如果已经播放完所有剪辑,循环回到第一个
            if (currentClipIndex >= animationMixerPlayable.GetInputCount())
                currentClipIndex = 0;

            // 获取当前剪辑的可播放项
            AnimationClipPlayable currentClip = (AnimationClipPlayable)animationMixerPlayable.GetInput(currentClipIndex);

            // 重置时间,使下一个剪辑从开头开始播放
            currentClip.SetTime(0f);

            // 设置距离下一个剪辑的时间为当前剪辑的长度
            timeToNextClip = currentClip.GetAnimationClip().length;
        }

        // 调整所有输入权重:当前播放的剪辑权重为 1.0,其他为 0.0
        for (int clipIndex = 0; clipIndex < animationMixerPlayable.GetInputCount(); clipIndex++)
        {
            if (clipIndex == currentClipIndex)
                animationMixerPlayable.SetInputWeight(clipIndex, 1.0f);
            else
                animationMixerPlayable.SetInputWeight(clipIndex, 0.0f);
        }
    }
}


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

×

喜欢就点赞,疼爱就打赏