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 组件建议按下面来:
- Controller:
None(避免 AnimatorController 抢控制权) - Avatar:
None(用 Transform 曲线直驱,不走 Humanoid) - Apply Root Motion:关闭(排除位移干扰)
- Culling Mode:
Always Animate(排查阶段建议使用)

AvatarMask 配置
在 Project 里创建 AvatarMask(Create/Avatar Mask),切到 Transform 面板:
- 导入骨骼列表:
Use skeleton from选择当前角色同源的骨骼来源(比如 Vayne),点击Import skeleton得到完整骨骼路径树
- 勾选规则: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.0,fadeInSeconds = 0.1,fadeOutSeconds = 0.12,游戏帧率 60 FPS(Time.deltaTime ≈ 0.0167)。
场景1:触发攻击(淡入)
初始状态:upperBodyLayerCurrentWeight = 0.0,isUpperBodyAttackActive = 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.0,isUpperBodyAttackActive = 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