9.PlayableGraph的分层混合

9.PlayableGraph的分层混合


9.1 知识点

目标、思路和步骤

用 Playable API 做分层混合,本质是:创建分层混合器,把不同动画接到不同层,再用 AvatarMask 限定每层影响的骨骼范围

本次的目标是:下半身持续跑(Run),上半身插入攻击(Attack)时仍保持跑步

如果用 AnimationMixerPlayable 做权重混合,权重是“整段姿势”的概念,不会自动理解“只混上半身”。要么 Attack 覆盖全身,要么需要准备一条只做上半身的 Attack 动画。

AnimationLayerMixerPlayable + AvatarMask 的价值就在这里:把动作拆成层(Layer),再用 Mask 限定层的骨骼影响范围

核心步骤

  • 创建图并设置更新模式
  • 创建动画输出,绑定到 Animator
  • 创建动画剪辑节点(Run 和 Attack)
  • 创建分层混合器(2 层:Layer0=Run,Layer1=Attack)
  • 连接动画剪辑到对应层
  • 设置 Layer1 的 AvatarMask(限定只影响上半身)
  • 设置初始权重(Layer0=1,Layer1=0)
  • 在 Update 中处理按键和权重淡入淡出
  • 清理资源

关键说明

  • AnimationLayerMixerPlayable
    AnimationLayerMixerPlayable 是 Playable 的分层混合器,相当于把 AnimatorController 的“动画层”抽出来单独用。每层权重独立,通过 AvatarMask 限定影响骨骼。Layer0 常驻播放,Layer1 及以上用于叠加,需要时再抬权重。

  • AvatarMask
    AvatarMask 用来限定某层能影响哪些骨骼。两套方式:Humanoid Mask(红绿小人)要求 Animator 有 Avatar 且映射正确;Transform Mask 按骨骼路径逐条过滤,不依赖 Humanoid。本例用 Transform Mask。

资源准备

  • runAnimationClip:跑步动画(建议循环)
  • attackAnimationClip:攻击动画(一般不循环)
  • upperBodyAvatarMask:上半身 AvatarMask(使用 Transform Mask)

场景准备

场景只需要 1 个角色,把脚本挂上即可。Animator 组件建议按下面来:

  • ControllerNone(避免 AnimatorController 抢控制权)
  • AvatarNone(用 Transform 曲线直驱,不走 Humanoid)
  • Apply Root Motion:关闭(排除位移干扰)
  • Culling ModeAlways Animate(排查阶段建议使用)

AvatarMask 配置

在 Project 里创建 AvatarMaskCreate/Avatar Mask),切到 Transform 面板:

  1. 导入骨骼列表:Use skeleton from 选择当前角色同源的骨骼来源(比如 Vayne),点击 Import skeleton 得到完整骨骼路径树
  2. 勾选规则:Layer1 只让上半身写入,腿和 root 都禁掉
    • 必须开启(上半身主干)root/spine/... 整条分支,左右手臂分支(通常挂在 root/spine/chest/... 下面)
    • 必须关闭(下半身整条分支)root/hip 以及它的所有子节点(l_thigh/r_thigh/...、脚、toe 等)
    • 建议关闭(避免”整体被带动”)root

注意事项
  • Transform Mask 的坑:Transform Mask 的每条路径都是独立开关,root/hip 设为 0 不会让 root/hip/l_thigh 自动变 0,所以腿分支的子节点也要全部关掉。
  • root 节点:腿骨都关了,但 Layer1 仍写 root 时整体姿态还是会被带动,建议把 root 也关掉。
  • Humanoid vs Transform:如果 Animator.Avatar 为空,Humanoid Mask 不生效,会回退到 Transform Mask。
优点

可以精确控制不同层影响不同骨骼,实现“上半身打、下半身跑”,不需要额外准备特殊的动画资源。

缺点

配置 AvatarMask 比较繁琐,Transform Mask 需要手动关掉整条分支;Humanoid Mask 需要 Avatar 映射正确。

代码实现

Start() 里搭图,把 Run 和 Attack 接到不同层,给 Layer1 套 AvatarMask;Update() 里处理按键与权重淡入淡出。

步骤1:创建图并设置更新模式

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

步骤2:创建动画输出

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

步骤3:创建动画剪辑节点

// 创建两个动画剪辑的可播放项:把 Run 和 Attack 包装成 Playable 节点
runClipPlayable = AnimationClipPlayable.Create(playableGraph, runAnimationClip);
attackClipPlayable = AnimationClipPlayable.Create(playableGraph, attackAnimationClip);

步骤4:创建分层混合器并连接动画剪辑

// 创建 2 层输入的分层混合器:Layer0=Run(底层),Layer1=Attack(叠层)
layerMixerPlayable = AnimationLayerMixerPlayable.Create(playableGraph, 2);

// 连接 Run 到 Layer0(底层,永远在播)
playableGraph.Connect(runClipPlayable, 0, layerMixerPlayable, 0);
// 连接 Attack 到 Layer1(叠层,需要时再抬起来)
playableGraph.Connect(attackClipPlayable, 0, layerMixerPlayable, 1);

步骤5:设置初始权重

// 初始化权重:Run 永远为底层 1;Attack 默认 0
layerMixerPlayable.SetInputWeight(0, 1f);
layerMixerPlayable.SetInputWeight(1, 0f);

步骤6:设置 Layer1 的 AvatarMask

// 给 Layer1 套上 AvatarMask,限定只影响上半身骨骼(实现“只影响上半身”的关键)
layerMixerPlayable.SetLayerMaskFromAvatarMask(1, upperBodyAvatarMask);
// 设置 Layer1 是否按叠加模式混合(false=覆盖模式,true=叠加模式,默认直接覆盖)
layerMixerPlayable.SetLayerAdditive(1, isUpperBodyLayerAdditive);

给 Layer1 套 AvatarMask,限定只影响上半身骨骼;并设置是否按叠加模式混合。
AvatarMask 是“只影响上半身”的关键,它告诉 Layer1 只能写上半身,腿不参与。
SetLayerAdditive() 控制混合方式,默认用覆盖模式(false),Layer1 直接覆盖 Layer0 的上半身姿态。

步骤7:连接输出并设置 Attack 初始状态

// 输出源设置为 LayerMixer
animationPlayableOutput.SetSourcePlayable(layerMixerPlayable);

// Attack 默认停住:避免未触发时一直跑时间
attackClipPlayable.SetSpeed(0d);

步骤8:播放图

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

步骤9:在 Update 中处理按键和权重更新

void Update()
{
    // 如果图无效,直接返回
    if (!playableGraph.IsValid())
        return;

    // 检测按键触发 Attack
    if (enableHotkeys && Input.GetKeyDown(startAttackKey))
    {
        StartUpperBodyAttack();
        Debug.Log($"{LogTag} {name} 按下 {startAttackKey}:触发上半身 Attack。");
    }

    // 检测按键停止 Attack
    if (enableHotkeys && Input.GetKeyDown(stopAttackKey))
    {
        StopUpperBodyAttack();
        Debug.Log($"{LogTag} {name} 按下 {stopAttackKey}:停止上半身 Attack(开始淡出)。");
    }

    // 更新 Layer1 权重(淡入淡出)
    UpdateUpperBodyLayerWeight();
    
    // 更新 Attack 时间并自动停止
    UpdateAttackLocalTimeAndAutoStop();
}

检测按键触发/停止 Attack;每帧更新 Layer1 权重(淡入淡出)和 Attack 时间。
按 J 触发、按 K 停止;权重平滑过渡,Attack 播完自动收掉,避免一直循环。

步骤10:实现 StartUpperBodyAttack 方法

private void StartUpperBodyAttack()
{
    // 打开攻击激活态,Layer1 权重会开始淡入
    isUpperBodyAttackActive = true;

    // 重置 Attack 时间指针到起点(从第一帧开始播放)
    attackLocalTimeSeconds = 0d;
    attackClipPlayable.SetTime(0d);

    // 开始播放 Attack(速度设为 1,正常播放)
    attackClipPlayable.SetSpeed(1d);

    // 立刻评估一次,让触发帧就能看到 Attack 的第一帧(立即刷新输出)
    playableGraph.Evaluate(0f);
}

触发上半身 Attack:打开攻击激活态,重置时间到起点,恢复播放速度(0 -> 1),并立即评估一次让第一帧显示出来。

打开激活态后,UpdateUpperBodyLayerWeight() 会开始把 Layer1 权重淡入到目标值。
重置时间确保每次从第一帧开始;Evaluate(0f) 用来立刻刷新输出。

步骤11:实现 StopUpperBodyAttack 方法

private void StopUpperBodyAttack()
{
    // 关闭攻击激活态,Layer1 权重会开始淡出
    isUpperBodyAttackActive = false;
    // 注意:这里不立刻把 Attack 速度设为 0,而是等权重完全淡出到 0 后再停(在 UpdateUpperBodyLayerWeight 中处理)
}

停止上半身 Attack。关闭攻击激活态(isUpperBodyAttackActive = false),不立刻停掉 Attack 速度。
权重淡出到 0 后再停,过渡更自然(在 UpdateUpperBodyLayerWeight() 中处理)。

步骤12:实现 UpdateUpperBodyLayerWeight 方法

private void UpdateUpperBodyLayerWeight()
{
    // 根据激活状态决定目标权重
    // 激活时:目标权重是 upperBodyLayerWeight(限制在 0-1 范围内)
    // 未激活时:目标权重是 0
    float targetWeight = isUpperBodyAttackActive ? Mathf.Clamp01(upperBodyLayerWeight) : 0f;

    // 选择淡入/淡出速度
    // 激活时用淡入时间,未激活时用淡出时间
    float durationSeconds = isUpperBodyAttackActive ? fadeInSeconds : fadeOutSeconds;
    // 防止除零,确保至少有一个很小的值
    durationSeconds = Mathf.Max(0.0001f, durationSeconds);

    // MoveTowards 的 maxDelta 是"每帧最多变化多少"
    // 用 deltaTime / duration 计算每帧应该变化多少,这样可以在指定时间内完成过渡
    float maxDelta = Time.deltaTime / durationSeconds;

    // 平滑过渡到目标权重
    upperBodyLayerCurrentWeight = Mathf.MoveTowards(upperBodyLayerCurrentWeight, targetWeight, maxDelta);

    // 设置 Layer1 权重(应用到分层混合器)
    layerMixerPlayable.SetInputWeight(1, upperBodyLayerCurrentWeight);

    // 当权重已经完全回到 0 时,把 Attack 停住,避免后台继续跑时间
    if (!isUpperBodyAttackActive && Mathf.Approximately(upperBodyLayerCurrentWeight, 0f))
    {
        if (attackClipPlayable.GetSpeed() != 0d)
            attackClipPlayable.SetSpeed(0d);
    }
}

主要作用:把 Layer1 权重平滑拉到目标值,并在权重回到 0 后停掉 Attack。
淡入/淡出的速度由 fadeInSeconds / fadeOutSeconds 控制,MoveTowards() 负责平滑过渡。

插值运算说明

数据变化示例

假设 upperBodyLayerWeight = 1.0fadeInSeconds = 0.1fadeOutSeconds = 0.12,游戏帧率 60 FPS(Time.deltaTime ≈ 0.0167)。

场景1:触发攻击(淡入)

初始状态:upperBodyLayerCurrentWeight = 0.0isUpperBodyAttackActive = true

帧数 Time.deltaTime targetWeight durationSeconds maxDelta upperBodyLayerCurrentWeight(变化前) upperBodyLayerCurrentWeight(变化后)
1 0.0167 1.0 0.1 0.167 0.0 0.167
2 0.0167 1.0 0.1 0.167 0.167 0.334
3 0.0167 1.0 0.1 0.167 0.334 0.501
4 0.0167 1.0 0.1 0.167 0.501 0.668
5 0.0167 1.0 0.1 0.167 0.668 0.835
6 0.0167 1.0 0.1 0.167 0.835 1.0(达到目标)

说明:每帧增加 maxDelta = 0.0167 / 0.1 = 0.167,约 6 帧(0.1 秒)从 0 过渡到 1.0。

场景2:取消攻击(淡出)

初始状态:upperBodyLayerCurrentWeight = 1.0isUpperBodyAttackActive = false

帧数 Time.deltaTime targetWeight durationSeconds maxDelta upperBodyLayerCurrentWeight(变化前) upperBodyLayerCurrentWeight(变化后)
1 0.0167 0.0 0.12 0.139 1.0 0.861
2 0.0167 0.0 0.12 0.139 0.861 0.722
3 0.0167 0.0 0.12 0.139 0.722 0.583
4 0.0167 0.0 0.12 0.139 0.583 0.444
5 0.0167 0.0 0.12 0.139 0.444 0.305
6 0.0167 0.0 0.12 0.139 0.305 0.166
7 0.0167 0.0 0.12 0.139 0.166 0.027
8 0.0167 0.0 0.12 0.139 0.027 0.0(达到目标,Attack 速度设为 0)

说明:每帧减少 maxDelta = 0.0167 / 0.12 ≈ 0.139,约 7-8 帧(0.12 秒)从 1.0 过渡到 0。当权重为 0 时,Attack 速度会被设为 0(停住)。

关键点

  • maxDelta = Time.deltaTime / durationSeconds 计算每帧的变化量,确保在 durationSeconds 时间内完成过渡。
  • MoveTowards() 会限制变化量不超过 maxDelta,如果距离目标值小于 maxDelta,直接跳到目标值。
  • 淡入和淡出使用不同的 durationSeconds,可以分别控制速度。

步骤13:实现 UpdateAttackLocalTimeAndAutoStop 方法

private void UpdateAttackLocalTimeAndAutoStop()
{
    // 如果 Attack 未激活,直接返回
    if (!isUpperBodyAttackActive)
        return;

    // 只有在 Attack 真的"出力"(权重开始抬起)时才推进时间
    // 这样可以避免在淡入阶段就开始计时
    if (upperBodyLayerCurrentWeight <= 0f)
        return;

    // 按游戏时间推进 Attack 的局部时间
    attackLocalTimeSeconds += Time.deltaTime;

    // 获取 Attack 动画的长度(防止除零,至少 0.001 秒)
    double clipLengthSeconds = Mathf.Max(0.001f, attackAnimationClip.length);
    
    // 如果播放到末尾,自动停止 Attack(进入淡出)
    if (attackLocalTimeSeconds >= clipLengthSeconds)
    {
        attackLocalTimeSeconds = clipLengthSeconds;  // 限制在长度范围内
        StopUpperBodyAttack();  // 触发停止,权重开始淡出
        Debug.Log($"{LogTag} {name} Attack 播放到末尾:自动停止(进入淡出)。");
    }
}

主要作用:每帧推进 Attack 的局部时间,播到末尾自动触发停止。
权重没抬起来时不计时,避免淡入阶段就把时间耗掉;attackLocalTimeSeconds 独立于 Clip 内部时间,便于判断是否播完。

步骤14:清理资源

void OnDisable()
{
    if (playableGraph.IsValid())
        playableGraph.Destroy();
}

效果展示


J 触发 Attack,下半身继续跑,上半身切到攻击;按 K 停止 Attack,上半身淡出回到跑步。Console 里会打印按键日志,便于对齐录制。


9.2 知识点代码

PlayableGraphLayerMixerUpperBodyMaskSample.cs

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

/// <summary>
/// PlayableGraph 分层混合示例组件
/// 该组件演示了如何使用 AnimationLayerMixerPlayable 和 AvatarMask 实现上半身攻击、下半身跑步的效果
/// Layer0:底层全身 Run(一直播放)
/// Layer1:上半身 Attack(用 AvatarMask 限定影响骨骼范围)
/// </summary>
[RequireComponent(typeof(Animator))]
public sealed class PlayableGraphLayerMixerUpperBodyMaskSample : MonoBehaviour
{
    /// <summary>
    /// 日志标签,用于统一日志输出格式
    /// </summary>
    private const string LogTag = "[PlayableGraphLayerMixerUpperBodyMaskSample]";

    [Header("动画资源")]
    /// <summary>
    /// 底层全身跑步动画(建议循环)
    /// 这个动画会一直在 Layer0 播放,作为底层动画
    /// </summary>
    [Tooltip("底层全身跑步动画(建议循环)。")]
    public AnimationClip runAnimationClip;

    /// <summary>
    /// 上半身攻击动画(一般不循环)
    /// 这个动画会在 Layer1 播放,通过 AvatarMask 限定只影响上半身骨骼
    /// </summary>
    [Tooltip("上半身攻击动画(一般不循环)。")] public AnimationClip attackAnimationClip;

    /// <summary>
    /// 上半身 AvatarMask,用于限定 Layer1 能影响的骨骼范围
    /// Humanoid Mask:需要 Animator 有 Avatar 且骨骼映射正确,勾上 Spine/Chest/Arms/Head,取消 Legs/Feet
    /// Transform Mask:不依赖 Humanoid 映射,按骨骼路径逐条过滤,只勾上半身骨骼
    /// </summary>
    [Tooltip("上半身 AvatarMask:Humanoid 勾上 Spine/Chest/Arms/Head,取消 Legs/Feet;Generic 则只勾上半身骨骼。")]
    public AvatarMask upperBodyAvatarMask;

    [Header("Layer1(上半身)混合参数")]
    /// <summary>
    /// Layer1 的目标权重,范围 0-1
    /// 1 表示完全覆盖上半身,0 表示不生效
    /// 当 Attack 激活时,Layer1 的权重会淡入到这个值
    /// </summary>
    [Tooltip("Layer1 的目标权重。1=完全覆盖上半身,0=不生效。")]
    [Range(0f, 1f)]
    public float upperBodyLayerWeight = 1f;

    /// <summary>
    /// Layer1 淡入时间(秒)
    /// 当 Attack 触发时,Layer1 权重从 0 淡入到 upperBodyLayerWeight 所需的时间
    /// 值越小淡入越快,建议 0.05 ~ 0.15 秒
    /// </summary>
    [Tooltip("Layer1 淡入时间(秒)。")] [Range(0.01f, 0.5f)]
    public float fadeInSeconds = 0.1f;

    /// <summary>
    /// Layer1 淡出时间(秒)
    /// 当 Attack 停止时,Layer1 权重从当前值淡出到 0 所需的时间
    /// 值越小淡出越快,建议 0.05 ~ 0.15 秒
    /// </summary>
    [Tooltip("Layer1 淡出时间(秒)。")] [Range(0.01f, 0.5f)]
    public float fadeOutSeconds = 0.12f;

    /// <summary>
    /// Layer1 是否按 Additive 方式叠加
    /// false:覆盖模式(默认),Layer1 直接覆盖 Layer0 的上半身姿态
    /// true:叠加模式,Layer1 在 Layer0 的基础上叠加偏移量
    /// 只有当 Attack 动画资源本身是 Additive(或姿势差值合理)时才建议开启
    /// </summary>
    [Tooltip("Layer1 是否按 Additive 方式叠加。只有当 Attack 动画资源本身是 Additive(或姿势差值合理)时才建议开启。")]
    public bool isUpperBodyLayerAdditive = false;

    [Header("输入")]
    /// <summary>
    /// 是否启用热键控制
    /// 多个对象挂脚本时建议只开一个,避免重复日志输出
    /// </summary>
    [Tooltip("是否启用热键。多个对象挂脚本时建议只开一个,避免重复日志。")]
    public bool enableHotkeys = true;

    /// <summary>
    /// 触发攻击的按键
    /// 按下此键会触发上半身 Attack,Layer1 权重开始淡入
    /// </summary>
    [Tooltip("触发攻击的按键。")] public KeyCode startAttackKey = KeyCode.J;

    /// <summary>
    /// 停止攻击的按键
    /// 按下此键会停止上半身 Attack,Layer1 权重开始淡出
    /// </summary>
    [Tooltip("停止攻击的按键。")] public KeyCode stopAttackKey = KeyCode.K;

    /// <summary>
    /// Animator 组件引用
    /// 用于接收 PlayableGraph 输出的动画数据
    /// </summary>
    private Animator animator;

    /// <summary>
    /// Playable 图,用于管理动画播放流程
    /// 包含所有 Playable 节点和输出节点
    /// </summary>
    private PlayableGraph playableGraph;

    /// <summary>
    /// 分层混合器,用于混合不同层的动画
    /// Layer0:底层全身 Run(权重永远为 1)
    /// Layer1:上半身 Attack(权重动态调整,0 到 upperBodyLayerWeight)
    /// </summary>
    private AnimationLayerMixerPlayable layerMixerPlayable;

    /// <summary>
    /// Run 动画剪辑的可播放项
    /// 连接到 LayerMixer 的 Layer0
    /// </summary>
    private AnimationClipPlayable runClipPlayable;

    /// <summary>
    /// Attack 动画剪辑的可播放项
    /// 连接到 LayerMixer 的 Layer1
    /// 默认速度设为 0(停住),触发时设为 1(开始播放)
    /// </summary>
    private AnimationClipPlayable attackClipPlayable;

    /// <summary>
    /// 是否处于攻击激活态
    /// true:Attack 已触发,Layer1 权重应该淡入到 upperBodyLayerWeight
    /// false:Attack 未触发或已停止,Layer1 权重应该淡出到 0
    /// 这个标志决定 UpdateUpperBodyLayerWeight() 中的目标权重值
    /// </summary>
    private bool isUpperBodyAttackActive;

    /// <summary>
    /// Layer1 当前权重(通过淡入/淡出平滑)
    /// 每帧通过 Mathf.MoveTowards() 平滑过渡到目标权重
    /// 范围:0 到 upperBodyLayerWeight
    /// </summary>
    private float upperBodyLayerCurrentWeight;

    /// <summary>
    /// Attack 的局部时间(秒)
    /// 用于在触发时重置到 0,并在播放到 clip.length 后自动停止
    /// 这个时间独立于 Attack Clip 的内部时间,用于判断是否播放完毕
    /// </summary>
    private double attackLocalTimeSeconds;

    /// <summary>
    /// 初始化并创建分层混合图
    /// </summary>
    private void Start()
    {
        // 获取 Animator 组件
        animator = GetComponent<Animator>();

        // 检查资源是否配置完整
        if (runAnimationClip == null || attackAnimationClip == null || upperBodyAvatarMask == null)
        {
            Debug.LogError(
                $"{LogTag} {name} Start:动画或 AvatarMask 未配置(runAnimationClip/attackAnimationClip/upperBodyAvatarMask 需要全部填写)。");
            enabled = false;
            return;
        }

        // 关键校验:AvatarMask 的"Humanoid 遮罩"需要 Animator.Avatar(并且通常要求 Humanoid Avatar)才能映射到骨骼
        // 若 Animator.Avatar 为空,则 Humanoid 的勾选不会生效,必须改用 Transform 遮罩
        bool hasTransformMask = upperBodyAvatarMask.transformCount > 0; // AvatarMask 是否配置了 Transform 列表
        bool hasAnimatorAvatar = animator.avatar != null; // Animator 是否有 Avatar
        bool isAnimatorHumanoid = hasAnimatorAvatar && animator.avatar.isHuman; // Avatar 是否是 Humanoid 类型

        // 如果 Animator 没有 Avatar,且 AvatarMask 也没有 Transform 列表,则遮罩不会生效
        if (!hasAnimatorAvatar)
        {
            if (!hasTransformMask)
            {
                Debug.LogError(
                    $"{LogTag} {name} Start:Animator.Avatar 为空,且 AvatarMask 也没有配置 Transform 列表。" +
                    " 这会导致 Layer1 的遮罩不生效,Attack 会影响全身。" +
                    " 解决方式:给 Animator 绑定 Humanoid Avatar,或把 AvatarMask 改成 Transform 遮罩。");
                enabled = false;
                return;
            }

            // 有 Transform Mask 但没 Avatar,会依赖 Transform 列表进行遮罩
            Debug.LogWarning(
                $"{LogTag} {name} Start:Animator.Avatar 为空,将依赖 AvatarMask 的 Transform 列表进行遮罩(Humanoid 勾选不会生效)。");
        }
        // 如果 Avatar 存在但不是 Humanoid,且没有 Transform Mask,则遮罩不会生效
        else if (!isAnimatorHumanoid && !hasTransformMask)
        {
            Debug.LogError(
                $"{LogTag} {name} Start:Animator.Avatar 存在但不是 Humanoid,同时 AvatarMask 也没有配置 Transform 列表。" +
                " 这会导致 Layer1 的遮罩不生效。" +
                " 解决方式:将模型 Rig 设置为 Humanoid 并生成 Avatar,或改用 Transform 遮罩。");
            enabled = false;
            return;
        }

        // Root Motion 会直接推动 GameObject 变换,和"腿部骨骼是否被遮罩"是两条链路
        // 为了让现象更"干净",建议先关闭 Root Motion
        if (animator.applyRootMotion)
        {
            Debug.LogWarning(
                $"{LogTag} {name} Start:Animator.ApplyRootMotion 已开启。" +
                " Root Motion 可能造成看起来下半身被影响整体位移变化的错觉。" +
                " 如需纯粹观察上半身遮罩效果,建议先关闭 Apply Root Motion。");
        }

        // 步骤1:创建 Playable 图并设置更新模式为 GameTime
        // GameTime 表示跟随游戏时间,受 Time.timeScale 影响
        playableGraph = PlayableGraph.Create("Lesson09_LayerMixer");
        playableGraph.SetTimeUpdateMode(DirectorUpdateMode.GameTime);

        // 步骤2:创建动画输出,连接到 Animator 组件
        // 这个输出负责把 PlayableGraph 计算出的动画结果输出到场景中的 Animator
        AnimationPlayableOutput animationPlayableOutput =
            AnimationPlayableOutput.Create(playableGraph, "Animation", animator);

        // 步骤3:创建两个动画剪辑的可播放项
        // 把 Run 和 Attack 两个动画剪辑包装成 Playable 节点
        runClipPlayable = AnimationClipPlayable.Create(playableGraph, runAnimationClip);
        attackClipPlayable = AnimationClipPlayable.Create(playableGraph, attackAnimationClip);

        // 步骤4:创建分层混合器并连接动画剪辑
        // 创建 2 层输入的分层混合器:Layer0=Run(底层),Layer1=Attack(叠层)
        layerMixerPlayable = AnimationLayerMixerPlayable.Create(playableGraph, 2);
        // 连接 Run 到 Layer0(底层,永远在播)
        playableGraph.Connect(runClipPlayable, 0, layerMixerPlayable, 0);
        // 连接 Attack 到 Layer1(叠层,需要时再抬起来)
        playableGraph.Connect(attackClipPlayable, 0, layerMixerPlayable, 1);

        // 步骤5:初始化权重
        // Layer0(Run)权重设为 1,永远在播(底层)
        layerMixerPlayable.SetInputWeight(0, 1f);
        // Layer1(Attack)权重设为 0,默认不生效
        layerMixerPlayable.SetInputWeight(1, 0f);

        // 步骤6:设置 Layer1 的 AvatarMask 和 Additive 模式
        // 给 Layer1 套上 AvatarMask,限定只影响上半身骨骼(这是实现"只影响上半身"的关键)
        layerMixerPlayable.SetLayerMaskFromAvatarMask(1, upperBodyAvatarMask);
        // 设置 Layer1 是否按叠加模式混合(false=覆盖模式,true=叠加模式,默认直接覆盖)
        layerMixerPlayable.SetLayerAdditive(1, isUpperBodyLayerAdditive);

        // 步骤7:连接输出并设置 Attack 初始状态
        // 把分层混合器设为输出的数据源,混合后的动画结果会输出到 Animator
        animationPlayableOutput.SetSourcePlayable(layerMixerPlayable);
        // Attack 默认把速度设为 0(停住),避免未触发时一直跑时间,等触发时再开始播
        attackClipPlayable.SetSpeed(0d);

        // 步骤8:播放图
        // 开始播放图,之后每帧 Unity 会自动更新图并应用到 Animator
        playableGraph.Play();

        // 初始化运行时状态
        upperBodyLayerCurrentWeight = 0f; // Layer1 当前权重初始为 0
        isUpperBodyAttackActive = false; // 攻击激活态初始为 false
        attackLocalTimeSeconds = 0d; // Attack 局部时间初始为 0

        Debug.Log(
            $"{LogTag} {name} Start:Graph 已创建,Layer0=Run,Layer1=Attack(upper-body masked),Additive={isUpperBodyLayerAdditive}。");
    }

    /// <summary>
    /// 每帧检测输入,控制动画播放状态
    /// 按 J 可以触发上半身 Attack,按 K 可以停止 Attack
    /// </summary>
    private void Update()
    {
        // 如果图无效,直接返回
        if (!playableGraph.IsValid())
            return;

        // 检测按键触发 Attack
        if (enableHotkeys && Input.GetKeyDown(startAttackKey))
        {
            StartUpperBodyAttack();
            Debug.Log($"{LogTag} {name} 按下 {startAttackKey}:触发上半身 Attack。");
        }

        // 检测按键停止 Attack
        if (enableHotkeys && Input.GetKeyDown(stopAttackKey))
        {
            StopUpperBodyAttack();
            Debug.Log($"{LogTag} {name} 按下 {stopAttackKey}:停止上半身 Attack(开始淡出)。");
        }

        // 更新 Layer1 权重(淡入淡出)
        UpdateUpperBodyLayerWeight();

        // 更新 Attack 时间并自动停止
        UpdateAttackLocalTimeAndAutoStop();
    }

    /// <summary>
    /// 触发上半身 Attack
    /// 重置 Attack 时间指针到 0,打开攻击激活态,恢复 Attack 播放速度
    /// </summary>
    private void StartUpperBodyAttack()
    {
        // 打开攻击激活态,Layer1 权重会开始淡入
        isUpperBodyAttackActive = true;

        // 重置 Attack 时间指针到起点(从第一帧开始播放)
        attackLocalTimeSeconds = 0d;
        attackClipPlayable.SetTime(0d);

        // 开始播放 Attack(速度设为 1,正常播放)
        attackClipPlayable.SetSpeed(1d);

        // 立刻评估一次,让触发帧就能看到 Attack 的第一帧(立即刷新输出)
        playableGraph.Evaluate(0f);
    }

    /// <summary>
    /// 停止上半身 Attack
    /// 关闭攻击激活态(权重会在 Update 里按 fadeOutSeconds 下落)
    /// Attack Clip 的速度不立刻归零,等权重淡出到 0 再停(观感更自然)
    /// </summary>
    private void StopUpperBodyAttack()
    {
        // 关闭攻击激活态,Layer1 权重会开始淡出
        isUpperBodyAttackActive = false;
        // 注意:这里不立刻把 Attack 速度设为 0,而是等权重完全淡出到 0 后再停(在 UpdateUpperBodyLayerWeight 中处理)
    }

    /// <summary>
    /// Layer1 权重更新(淡入淡出)
    /// isUpperBodyAttackActive=true  -> 向 upperBodyLayerWeight 淡入
    /// isUpperBodyAttackActive=false -> 向 0 淡出
    /// </summary>
    private void UpdateUpperBodyLayerWeight()
    {
        // 根据激活状态决定目标权重
        // 激活时:目标权重是 upperBodyLayerWeight(限制在 0-1 范围内)
        // 未激活时:目标权重是 0
        float targetWeight = isUpperBodyAttackActive ? Mathf.Clamp01(upperBodyLayerWeight) : 0f;

        // 选择淡入/淡出速度
        // 激活时用淡入时间,未激活时用淡出时间
        float durationSeconds = isUpperBodyAttackActive ? fadeInSeconds : fadeOutSeconds;
        // 防止除零,确保至少有一个很小的值
        durationSeconds = Mathf.Max(0.0001f, durationSeconds);

        // MoveTowards 的 maxDelta 是"每帧最多变化多少"
        // 用 deltaTime / duration 计算每帧应该变化多少,这样可以在指定时间内完成过渡
        float maxDelta = Time.deltaTime / durationSeconds;
        // 平滑过渡到目标权重
        upperBodyLayerCurrentWeight = Mathf.MoveTowards(upperBodyLayerCurrentWeight, targetWeight, maxDelta);

        // 设置 Layer1 权重(应用到分层混合器)
        layerMixerPlayable.SetInputWeight(1, upperBodyLayerCurrentWeight);

        // 当权重已经完全回到 0 时,把 Attack 停住,避免后台继续跑时间
        if (!isUpperBodyAttackActive && Mathf.Approximately(upperBodyLayerCurrentWeight, 0f))
        {
            if (attackClipPlayable.GetSpeed() != 0d)
                attackClipPlayable.SetSpeed(0d);
        }
    }

    /// <summary>
    /// Attack 的局部时间推进与自动停止
    /// 触发后按 Time.deltaTime 推进 attackLocalTimeSeconds
    /// 播放到 clip.length 后自动 StopUpperBodyAttack(),进入淡出
    /// </summary>
    private void UpdateAttackLocalTimeAndAutoStop()
    {
        // 如果 Attack 未激活,直接返回
        if (!isUpperBodyAttackActive)
            return;

        // 只有在 Attack 真的"出力"(权重开始抬起)时才推进时间
        // 这样可以避免在淡入阶段就开始计时
        if (upperBodyLayerCurrentWeight <= 0f)
            return;

        // 按游戏时间推进 Attack 的局部时间
        attackLocalTimeSeconds += Time.deltaTime;

        // 获取 Attack 动画的长度(防止除零,至少 0.001 秒)
        double clipLengthSeconds = Mathf.Max(0.001f, attackAnimationClip.length);

        // 如果播放到末尾,自动停止 Attack(进入淡出)
        if (attackLocalTimeSeconds >= clipLengthSeconds)
        {
            attackLocalTimeSeconds = clipLengthSeconds; // 限制在长度范围内
            StopUpperBodyAttack(); // 触发停止,权重开始淡出
            Debug.Log($"{LogTag} {name} Attack 播放到末尾:自动停止(进入淡出)。");
        }
    }

    /// <summary>
    /// 组件禁用时清理资源,销毁 Playable 图及其所有可播放项和输出
    /// </summary>
    private void OnDisable()
    {
        // 检查图是否有效,然后销毁所有资源(包括节点和输出),避免内存泄漏
        if (playableGraph.IsValid())
            playableGraph.Destroy();
    }
}


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

×

喜欢就点赞,疼爱就打赏