Unity 中使用 Playable 实现简单的动画播放
Unity 中使用 Playable 实现简单的动画播放
在 Unity 中,Animator 组件大概是每个初学者都会接触到的第一个动画组件,它提供可视化的方式处理各个动画的过渡连接,并利用自定义参数作为过渡条件。但当涉及的动画多起来时,这个结构会变得十分复杂,好在 Unity 有提供 Animator 播放动画相关的函数,让我们可以代码控制动画的播放,这样可以省去手动连接的过程(当然,这可能更适用于个人开发,团队合作中,Animator的可视化连接可能是专人负责的)。
但即便如此,在面对 BlendTree 混合动画时,我们还是不得不用 Animator 中的参数。如果对 Animator 面板上参数名进行了变更,还需要同步在代码调用的相关地方进行更改,十分容易出问题。而且 Animator 不支持动态修改,需要将所有用到的动画都加到 Animator 中或制作多个 Animator 分别使用……那有没有能够解决 Animator 不便之处的办法呢?有的,就是 Playable API, Animator 的底层就是用到了它,我们完全可以用 Playable API 做一个更契合自身需求的 Animator,让我们来试试该怎么做吧。
PS: 本文的示例项目的完整内容可见gitee
Playable API 与动画播放
Playable API 从名字可以看出来,就是用来控制那些可以播放的东西的方法,比如音频播放、动画播放甚至还有脚本的运行,详情可以看Unity的官方文档。
那这是一种怎样的控制方法呢?其实就是一个树状结构,通过不同权重混合其中的所有节点来得到最终输出,借助 PlayableGraph Visualizer 插件 可以看到具体结构。
比如下面是一个用 Playable API 做的动画播放器(这也是后面我们要实现的):
这个图我们从右往左看,最右侧的 AnimationClip 节点,对应 Playable API 中的 AnimationClipPlayable 结构体,是真正直接承载动画信息的节点,每个都相当于 Animator 中的一个 Animation Clip:
再往左看,可以注意到 AnimationMixer 连接着至少一个 AnimationClip 或者 其他的AniamtionMixer,它对应 Playable API 中的 AnimationMixerPlayable 结构体,负责以不同权重将输入的动画信息进行混合,这和 Animator 中的 BlendTree 混合树所做的是类似的。
AnimationMixer 十分重要,它是得以将不同动画进行融合的关键,包括想要实现 Aniamtor 中动画过渡切换的效果,也需要依赖它。
顺带一提,图中连接着节点的边便是一个个 值在 0~1 的权重,如果我告诉你示例中的动画播放器上,白色的边都表示权重为1,暗淡的灰色的边都表示权重为0,你能分析出最终播放的动画受哪个 AnimationClip 的影响最大吗?
答案是,最右侧从上往下数的第四个 AnimationClip,因为其他看似有被连接的,都在向左传播的过程中因某个地方权重为0而中止掉了:
然后是 AnimationLayerMixer,它对应 Playable API 中的 AnimationLayerMixerPlayable 结构体,它将不同的输入动画以“层”的形式进行混合。什么意思呢?它其实就是做了 Animator 中分层动画的工作,指定哪些动画用于哪些身体部位,以怎样的形式进行混合,这在3D动画中比较常见。
示例中最下方的被连接的 AnimationMixer 用于控制上半身 AvatarMask 的动画,且他最终得到的是一个“挥拳”动作,在奔跑的同时挥拳的话,就是这样的效果:
最后就是 AnimationOutput,它对应 Playable API 中的……我知道,是 AnimationOutputPlayable 结构体对吧,实则并非,它对应的是 AnimationPlayableOutput 结构体 (就它命名格式不一样,它是最终输出,它所接受的动画才是真正会被播放的动画。
现在简单总结一下:
AnimationClip,用于承载单个动画,只能作为输入;AnimationMixer,用于以不同权重混合所接受的动画输入,可以用作输入和输出,并且数量都可以自定义;AnimationLayerMxier,用于整合不同层的动画输入,让动画得以应用在指定的AvatarMask上;AnimationOutput,是最终动画输出口。
还有一个用于动画的 Playable API 的结构体我们没有提及—— AnimatorControllerPlayable,它将整个 Animator 作为节点,可以用于一些需要多个 Animator 混用的情况,本文内容主要是想实现一个简单的 Animator,用不上这个。
初识:创建并播放一个简单的 PlayableGraph
创建 PlayableGraph
现在我们通过创建一个简单的 PlayableGraph 来看看实际初始化、连接节点、设置权重等这些基础功能该调用哪些函数来完成,首先创建一个 PlayableTest 的 Monobehaviour 脚本(后面做解释):
using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;
[RequireComponent(typeof(Animator))]
public class PlayableTest : MonoBehaviour
{
public AnimationClip clip;
private PlayableGraph graph;
private void Start()
{
graph = PlayableGraph.Create("New Graph");
graph.SetTimeUpdateMode(DirectorUpdateMode.GameTime);
AnimationClipPlayable playableClip = AnimationClipPlayable.Create(graph, clip);
AnimationMixerPlayable mixer = AnimationMixerPlayable.Create(graph, 1);
AnimationLayerMixerPlayable layerMixer = AnimationLayerMixerPlayable.Create(graph, 2);
AnimationPlayableOutput output = AnimationPlayableOutput.Create(graph, "Output", GetComponent<Animator>());
graph.Connect(playableClip, 0, mixer, 0);
graph.Connect(mixer, 0, layerMixer, 0);
output.SetSourcePlayable(layerMixer);
}
private void OnDestroy()
{
graph.Destroy();
}
}
这里快速插播一下 PlayableGraph Visualizer 插件 的安装:打开插件的 GitHub 界面后,复制它的 git URL
在 Unity 的 Window / Package Manager 中选择通过 git URL 添加插件(粘贴后再点击 Add 就好了)
将它挂载在场景的一个空物体上并运行编辑器,我们可以看到 PlayableGraph Visualizer 中出现了这样一张图:
让我们看看这是怎么来的:首先,我们用 PlayableGraph.Create("New Graph") 创建了一个名为「New Graph」的图,这在上图的左上角可以体现。PlayableGraph.Create() 函数用于 PlayableGraph 结构体的初始化,可以接收一个字符串参数作为图的名字。
还用 SetTimeUpdateMode 设置了图的更新方式为「GameTime」,这意味着 graph 的动画播放会跟随 Time.time,受到 timeScale 的影响,当游戏暂停时,动画播放也会暂停。
初始化创建与设置更新方式可以说是创建 PlayableGraph 必须的两个步骤。
接下来我们创建了上图的4个节点,可以发现创建都是调用 XXXX.Create() 函数只是传入的参数不一样,而 XXXX 就是被创建的这个结构体对应的类别名。
AnimationClipPlayable playableClip = AnimationClipPlayable.Create(graph, clip);
AnimationMixerPlayable mixer = AnimationMixerPlayable.Create(graph, 1);
AnimationLayerMixerPlayable layerMixer = AnimationLayerMixerPlayable.Create(graph, 2);
AnimationPlayableOutput output = AnimationPlayableOutput.Create(graph, "Output", GetComponent<Animator>());
可以发现,它们创建时传入的第一个参数都是 PlayableGraph,用于指定它们创建是用于哪个图,对于 AnimationClipPlayable, 还需要传入一个 AnimationClip 用于指定其播放的动画。
AnimationPlayableOutput 需要再传入两个参数,一个是自身的命名,一个是 Animator 组件。也就是说其实在使用 Playable API 处理动画时仍需 Aniamtor 作为动画输出。
AnimationMixerPlayable 和 AnimationLayerMixerPlayable 则类似,都需要再传入一个整数用于指定,它有几个用于输入口。输入口可以看作这个节点右侧连接边的地方,也就是与其他节点输出相接的地方。
来看个示例,你能判断图中间的 AnimationMixer 节点有几个输入口吗?
emmm,它的右侧与4个节点相连,应当有至少4个输入口 ,也就是说,它在创建时至少是这样的:
AnimationMixerPlayable mixer = AnimationMixerPlayable.Create(graph, 4);
为什么说是至少呢?因为 PlayableGraph Visualizer 只可视化了有连接的部分,也许它还有些输入口没被连接导致没在图中显示而已。这也解释了为什么代码中,我们创建的有两个输入口的「layerMixer」看似只有一个:
AnimationLayerMixerPlayable layerMixer = AnimationLayerMixerPlayable.Create(graph, 2);
至于连接,除了 AnimationPlayableOutput 是使用 SetSourcePlayable 函数直接指定输入对象外,其他的都是需要 Playable Graph 实例使用 Connect 函数指定二者的连接:
graph.Connect(playableClip, 0, mixer, 0);
graph.Connect(mixer, 0, layerMixer, 0);
output.SetSourcePlayable(layerMixer);
就以「playableClip」与「mixer」的连接为例,所传入的4个参数的含义是 「将playableClip的0号输出,连接到mixer的0号输入」,结合我们先前提到的输入输出的具体形式,应该不难理解。因为一个节点可能有多个输入输出口,以数组的形式存储,在可视化中的体现便是同一列,从上到下,下标从0开始:
PS:与 Connect 相对是 Disconncect 函数,用于取消已有的连接,它接受两个参数,一个是需要进行取消的对象,一个是所需取消的输入口编号。这两个函数是得以动态增减动画的关键。
void PlayableGraph.Disconnect<U>(U input, int inputPort) where U : struct, IPlayable
如果要将结构改成这样,你知道该怎么做吗:
可以这样做:
graph.Connect(playableClip, 0, layerMixer, 1); // 连接到 layerMixer 1号输入口
graph.Connect(mixer, 0, layerMixer, 0);
output.SetSourcePlayable(layerMixer);
现在我们将结构改回最开始的那样,然后进入下一个「播放」环节。
播放 PlayableGraph
现在我们在脚本的 Start 函数继续添加这段代码,将「layerMixer」与「mixer」各自接受的0号输入口的权重设置为1,并让「graph」启动运行:
private void Start()
{
……
mixer.SetInputWeight(0, 1);
layerMixer.SetInputWeight(0, 1);
graph.Play();
}
如果你没有为 PlayableTest 脚本添加上 AnimationClip,那可以点击图中节点,通过观察右侧这边的「Time」值是否有变动:
这个值有变动就说明它有在进行更新,这也引出了一个问题,那就是当我们把输入的权重置为0时,动画看似没有播放,但其实还是在更新中的。将先前的代码改成这样,再观察「Time」值可以证明这一点:
private void Start()
{
……
mixer.SetInputWeight(0, 0);
layerMixer.SetInputWeight(0, 0);
graph.Play();
}
我们不希望一个没有被表现出来的动画也在默默播放,有办法真的让它停止吗,有的兄弟有的,可以单独设置节点的播放状态,用 .Pause() 来暂停,用 .Play() 来播放(创建时默认就是播放状态):
private void Start()
{
……
mixer.SetInputWeight(0, 0);
mixer.Pause();
layerMixer.SetInputWeight(0, 0);
layerMixer.Pause();
graph.Play();
}
现在节点都没有在更新了。不过奇怪的是,我们并没有暂停「playableClip」,且在右侧的播放状态中也显示是「Playing」,但也没更新:
这体现了 Playable API 树状结构的特点:以 AnimationOutput 直接相连的节点 作为树的根,向另一侧进行逐级下发更新指令。这么一来,两个相连的节点,左侧的是父节点,右侧的是子节点。当父节点被暂停时,子节点无论自身播放状态如何也都被暂停了。
因此,即使将代码中的 mixer.Pause() 去掉,「mixer」、「playableClip」也仍不会更新,因为 「layerMixer」 是暂停状态的。这个特点可以很方便地让我们批量激活与暂停动画。
还有一个问题,如果一个动画在「Time」为 0.5s 时暂停了,当继续播放时它会继续从 0.5s 处继续,有办法让它们从头开始甚至从指定时间开始吗?这就需要用到 .SetTime() 函数了,而一般来说,只有 AnimationClipPlayable 结构体使用 SetTime 函数比较有意义,它会实际影响到动画播放的起始位置。
尝试实际应用
可以更进一步了,我们将 Playable API 的动画播放与有限状态机控制的角色结合起来,看看实际开发中要怎么应用它。
不过,在此之前还是需要一些前置工作的,当 Playable Graph 里的动画很多时,该如何合理控制它们的播放与期望的衔接呢?这势必会涉及很多 AnimationMixerPlayable 的控制,让我们写一些类来方便 Playable 动画的组织吧。
注意!注意! 下文这些只是个人认为可以方便控制的,因为 Playable API 提供了细腻的控制方式,这也意味着实际使用起来其实很琐碎,简单的切换动画播放都可能需要调用多个函数(取决于播放的细致程度),因此我觉得将一些函数整合到一起、把一些连接结构固定下来会简化不少。但这也简化也不过是符合我个人想法的,仅供参考。
通过先前的了解,我们知道使用 Playable API 进行动画过渡或者混合动画必须得用到 AnimationMixerPlayable。而一个 AnimationMixerPlayable 它既可能只是简单的连接着一个 AnimationClipPlayable 也有可能是连接着另一个复杂的 AnimationMixerPlayable。
PlayableBlendAnimator类
先创建一个类,它内部控制着一个AnimationMixerPlayable,并只接收 AnimationClipPlayable 作为输入。
我们将用它作为最基础的播放单元,如果它只有一个 AnimaitonClipPlayable 输入的话,就相当于 Animator 中的一个 AnimationClip;如果有多个,就相当于一个 BlendTree 动画。
先看下整体代码,后面再解释下如此设计的想法:
using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;
public class PlayableBlendAnimator
{
public AnimationMixerPlayable Mixer => mixer;
private int currentPlayIndex;
private AnimationMixerPlayable mixer;
public PlayableBlendAnimator(ref PlayableGraph graph, AnimationClip[] clips)
{
mixer = AnimationMixerPlayable.Create(graph, clips.Length);
// 连接传入的动画片段,并将第一个以外的动画权重置都0并设置为停止
for (int i = 0; i < clips.Length; ++i)
{
graph.Connect(AnimationClipPlayable.Create(graph, clips[i]), 0, mixer, i);
mixer.GetInput(i).Pause();
}
mixer.SetInputWeight(0, 1);
mixer.GetInput(0).Play();
mixer.Pause(); // 将mixer暂停,保证初始不会播放任何动画
}
public void PlayClip(int playIndex)
{
MixerPauseClip(currentPlayIndex); // 暂停当前播放的动画
currentPlayIndex = playIndex; // 更新当前播放的动画所属下标
MixerPlayClip(currentPlayIndex); // 播放当前动画
}
public void SetInputWeight(int inputPort, float weight)
{
if (weight > 0)
{
// 当权重大于0时,视为进入播放,并更新currentPlayIndex
mixer.GetInput(inputPort).Play();
currentPlayIndex = inputPort;
}
else
{
// 暂停并将动画时间重置回0
var clipPlayable = mixer.GetInput(inputPort);
clipPlayable.SetTime(0);
clipPlayable.Pause();
}
mixer.SetInputWeight(inputPort, weight);
}
public void SetTime(int inputPort, float time)
{
mixer.GetInput(inputPort).SetTime(time);
}
public bool IsAnimationClipEnd()
{
// 通过当前播放动画的时间与 其Clip的时长来判断播放完毕
var curPlayableClip = (AnimationClipPlayable)mixer.GetInput(currentPlayIndex);
return curPlayableClip.GetTime() >= curPlayableClip.GetAnimationClip().length;
}
public void Destroy()
{
if (!mixer.IsValid())
{
return;
}
int clipsLength = mixer.GetInputCount();
for (int i = clipsLength - 1; i >= 0; --i)
{
var input = mixer.GetInput(i);
if (input.IsValid())
{
input.Destroy();
}
}
mixer.Destroy();
}
private void MixerPlayClip(int clipIndex)
{
// 将指定序号的动画,权重设为1、时间设为0(从头开始)并播放
mixer.SetInputWeight(clipIndex, 1f);
var clipPlayable = mixer.GetInput(currentPlayIndex);
clipPlayable.SetTime(0);
clipPlayable.Play();
}
private void MixerPauseClip(int clipIndex)
{
// 将指定序号的动画,权重设为0并暂停
mixer.SetInputWeight(clipIndex, 0f);
mixer.GetInput(clipIndex).Pause();
}
}
这个类的 mixer 是与其他节点连接的唯一对象,而正因为它被连接作为输入,所以接收输入的节点可以通过 .GetInput(inputPortIndex).Play() 来播放在 inputPortIndex 输入下标的mixer(事实上,这个函数并不关心那个输入下标具体连接的是什么,总之都会被播放)。我们要确保,mixer被播放后,AnimationClipPlayable 就也可以正常呈现了。下图便是3个 PlayableBlendAnimator 对象被连接到同一个 AnimatiorMixerPlayable 上。
现在我来预判回答下大家的疑惑:
- 构造函数中既然 mixer 最后暂停了,为什么还要将除了第一个以外的动画都暂停呢?以及把第一个动画设置为播放有什么用处吗?
因为 mixer 是最终与其他节点进行连接的对象,当 mixer 被激活播放时,只有一个动画的
PlayableBlendAnimator就能直接播放动画了。对于有多个动画的 mixer 而言,必然会用到自定义的逻辑来指定这多个动画该如何播放,此时将其他动画设置为暂停可以防止干扰,有必要也可以将第一个动画手动停止,或者把第一个动画的位置交给「起始动画」。举个例子,假设有个「跳跃」状态,当玩家输入跳跃指令时,角色进入「跳跃」状态,状态内部会播放跳跃相关的动画。但考虑到跳跃的细节,其动画可能不止一个,可能有 跳起、跳起转下落、下落、着落 这四个,根据垂直方向的速度来决定这四个动画各自的播放权重,我们就可以将跳起动画放在 mixer 的0号输入。
Destory函数是用于销毁吗,为什么还需要逆序遍历销毁它的输入——那些AnimationClipPlayable对象?答案不言自明,因为根据官方文档中的介绍
mixer.Destroy()仅会清除自身,而它的输入们都还在PlayableGraph中,只是不会更新了而已。- 这个类中有个
currentPlayIndex整数记录当前播放的动画的下标,在用SetInputWeight设置权重时,仅仅是weight > 0就更新currentPlayIndex会不会太草率了?在这个类中,使用
currentPlayIndex主要是想判断动画播放是否结束。不是直接调用类里的PlayClip函数,而是采用SetInputWeight的情况,一般用于动画过渡,从动画A过渡到动画B,动画A的权重会递减至0,而动画B的权重会递增至1。在这个过渡阶段,
currentPlayIndex只取决于你最后是对谁SetInputWeight,如果你在实现这个过渡是,先用SetInputWeight变化动画A的权重,后变化动画B的权重,那currentPlayIndex就是动画B的下标。至于究竟要哪个放在最后,就是你决定的了。但最终,由于动画A减小到0了,自然也就不满足
weight > 0的情况了,所以过渡完成后currentPlayIndex只会是动画B的下标。
PlayableLayerAnimator 类
接下来是 PlayableLayerAnimator 类,它充当一层的总动画播放,与 Aniamtor 中的单层动画播放定位相近。它的本质也是控制一个 AnimationMixerPlayable,注意,并不是 AnimationLayerMixerPlayable,只不过它最后会将输出的动画输入到 AimationLayerMixerPlayable 中。
同理,先看下实现,整体思路与上一个的实现是类似的,只不过由于要用统一的动画过渡函数,用到和委托以及更多的辅助跟踪的变量:
using UnityEngine.Playables;
using UnityEngine.Animations;
using UnityEngine;
[System.Serializable]
public class PlayableLayerAnimator
{
public AnimationMixerPlayable RootMixer => rootMixer;
public AvatarMask Mask; // 指定影响的mask
public System.Action UpdateMethod; // 用于动画过渡函数的委托
public bool isAdditive; // mask动画的覆盖方式是否为附加模式,false的话为覆盖模式
private AnimationMixerPlayable rootMixer;
private int lastPlayedIndex, curPlayingIndex;
private float elapsedTime, duration; // 分别用于记录当前过渡时间与总过渡所需时间
public void Init(ref PlayableGraph graph, int inputPortCount)
{
rootMixer = AnimationMixerPlayable.Create(graph, inputPortCount);
}
public void PlayClip(int clipIndex)
{
// 可能仍处于过渡阶段,将当前过渡的前者动画停止(后者会在正常切换流程中置0)
MixerPauseClip(lastPlayedIndex);
UpdateMethod = null; // 取消过渡动画的更新
lastPlayedIndex = curPlayingIndex;
curPlayingIndex = clipIndex;
MixerPauseClip(lastPlayedIndex);
MixerPlayClip(curPlayingIndex);
}
public void CrossFade(int clipIndex, float normalizedTransition, float clipLength)
{
normalizedTransition = Mathf.Clamp01(normalizedTransition);
if (clipIndex == curPlayingIndex || normalizedTransition == 0)
{
PlayClip(clipIndex);
return;
}
MixerPauseClip(lastPlayedIndex); // 暂停上次动画(防的是上次也是CrossFade播放的情况)
lastPlayedIndex = curPlayingIndex;
curPlayingIndex = clipIndex;
rootMixer.GetInput(lastPlayedIndex).Play();
rootMixer.GetInput(curPlayingIndex).Play();
elapsedTime = 0;
duration = normalizedTransition * clipLength;
UpdateMethod -= CrossFadeOnUpdate; // 防止反复添加
UpdateMethod += CrossFadeOnUpdate;
}
public void Destroy()
{
UpdateMethod = null;
for (int i = rootMixer.GetInputCount() - 1; i >= 0; --i)
{
var input = rootMixer.GetInput(i);
if (input.IsValid())
{
input.Destroy();
}
}
rootMixer.Destroy();
}
private void MixerPlayClip(int clipIndex)
{
rootMixer.SetInputWeight(clipIndex, 1f);
rootMixer.GetInput(clipIndex).Play();
}
private void MixerPauseClip(int clipIndex)
{
rootMixer.SetInputWeight(clipIndex, 0f);
rootMixer.GetInput(clipIndex).Pause();
}
private void CrossFadeOnUpdate()
{
if (elapsedTime < duration)
{
elapsedTime += Time.deltaTime;
float t = Mathf.Clamp01(elapsedTime / duration);
rootMixer.SetInputWeight(lastPlayedIndex, 1 - t);
rootMixer.SetInputWeight(curPlayingIndex, t);
}
else
{
MixerPauseClip(lastPlayedIndex);
UpdateMethod -= CrossFadeOnUpdate;
}
}
}
可以注意到,这个类有 [System.Serializable] 序列化标记,它的一些变量需要我们在编辑器界面进行设置(主要是 Mask 和 isAdditive,下一节会提到用处),因此没采用构造函数进行初始化,而是使用一个专门的初始化函数 Init()。
动画过渡注定是个持续的过程,如果想调用一次函数就让它能持续运行下去,要么用协程,要么将逻辑放在会被持续调用的另一个函数中。这里采用后者,并以委托的方式来挂载。
如果你不知道什么是“委托”,你可以简单理解为将一个函数传到一个变量中,当那个变量被调用时函数也会跟着调用。这里就是将 CrossFadeOnUpdate 函数传到 UpdateMethod 中,当 UpdateMethod 被调用时,CrossFadeOnUpdate 就也会被调用了。
为了方便管理动画播放,这里除了记录当前播放的动画 curPlayingIndex 以外,还有一个用于记录先前播放的动画 lastPlayedIndex。在 CrossFade 函数更新 curPlayingIndex 和 lastPlayedIndex 之前要先 MixerPauseClip(lastPlayedIndex) 暂停上上次播放的动画,防止其干扰过渡(过渡应只在两个动画间发生)。
PlayableAnimator类
最后是 PlayableAnimator,它用一个 AnimationLayerMixerPlayable 统合一系列 PlayableLayerAnimator 动画输入:
using UnityEngine;
using UnityEngine.Animations;
using UnityEngine.Playables;
[System.Serializable]
public class PlayableAnimator
{
public PlayableGraph Graph => graph;
public AnimationLayerMixerPlayable RootLayerMixer { get; set; }
public PlayableLayerAnimator[] LayerAnimators;
private PlayableGraph graph;
public void InitRoot(string graphName, Animator baseAnimator)
{
graph = PlayableGraph.Create(graphName);
graph.SetTimeUpdateMode(DirectorUpdateMode.GameTime);
RootLayerMixer = AnimationLayerMixerPlayable.Create(graph, LayerAnimators.Length);
var output = AnimationPlayableOutput.Create(graph, graphName + "_Output", baseAnimator);
output.SetSourcePlayable(RootLayerMixer);
graph.Play();
}
// 为各个 PlayableLayerAnimator 进行初始化并连接至自身 RootLayerMixer
public void InitLayerAnimator(uint animIndex, int inputPortCount)
{
LayerAnimators[animIndex].Init(ref graph, inputPortCount);
RootLayerMixer.SetLayerAdditive(animIndex, LayerAnimators[animIndex].isAdditive);
if (LayerAnimators[animIndex].Mask != null)
{
RootLayerMixer.SetLayerMaskFromAvatarMask(animIndex, LayerAnimators[animIndex].Mask);
}
graph.Connect(LayerAnimators[animIndex].RootMixer, 0, RootLayerMixer, (int)animIndex);
SetLayerAnimatorActivated((int)animIndex, false);
}
public void SetLayerAnimatorActivated(int layerAnimatorIndex, bool isActivated)
{
if (isActivated)
{
RootLayerMixer.SetInputWeight(layerAnimatorIndex, 1);
RootLayerMixer.GetInput(layerAnimatorIndex).Play();
}
else
{
RootLayerMixer.SetInputWeight(layerAnimatorIndex, 0);
RootLayerMixer.GetInput(layerAnimatorIndex).Pause();
}
}
// 统一更新各个UpdateMethod
public void OnUpdate()
{
for (int i = 0; i < LayerAnimators.Length; ++i)
{
LayerAnimators[i].UpdateMethod?.Invoke();
}
}
public void Destroy()
{
var layreAnimatorLength = LayerAnimators.Length;
for (int i = layreAnimatorLength - 1; i >= 0; --i)
{
LayerAnimators[i].Destroy();
}
graph.Destroy();
}
}
在这里可以看到先前 PlayableLayerAnimator 的 Mask 和 isAdditive 是用来给 AnimationLayerMixerPlayable 相关函数做参数用的。
可能你也发现了,这个 PlayableAnimator 类中管理着先前的 PlayableLayerAnimator 类,但 PlayableLayerAnimator 类却不管理再先前的 PlayableBlendAnimator 类,为什么这么安排呢?
因为 PlayableLayerAnimator 和 PlayableLayerAnimator 是用来构成整体动画播放控制的系统,类似 Unity 的 Animator 中的各层动画以及每层动画之间的衔接方式,但具体每个动画内部的切换,交由外界具体控制。接下来,看个实例能帮助理解。
与有限状态机的结合使用
完整的示例项目和开头给出的一样,可以戳这里。它分别简单创建了两个控制对象,一个2D角色(小红帽角色来自该itch网站)、一个3D角色。
它们均由有限状态机进行控制,有闲置、移动、跳跃、攻击四个状态,且以 ScriptableObject 方式保存。在闲置状态时,按键A可以进入移动状态,按键空格进入跳跃状态,按键J进入攻击状态。下面只挑一些稍微值得一提的地方说说。
首先,在直接用于角色控制的脚本 RedHoodController 或 RobotController 中都可以看到,Update 函数中调用了 PlayableAnimator 的 OnUpdate,这确保了动画内部的过渡更新能生效:
public class RedHoodController : MonoBehaviour
{
public FSM<RedHoodState> m_FSM;
public PlayableAnimator m_Animator;
[SerializeField] private RedHoodState[] stateTable;
public RedHoodState EX_RunState;
private void Awake()
{
m_FSM = new FSM<RedHoodState>();
InitPlayableGraph();
}
private void Start()
{
m_FSM.SwitchOn(typeof(RedHood_Idle));
}
private void Update()
{
m_FSM.OnUpdate();
m_Animator.OnUpdate();
}
private void FixedUpdate()
{
m_FSM.OnFixedUpdate();
}
private void OnDestroy()
{
m_Animator.Destroy();
}
private void InitPlayableGraph()
{
m_Animator.InitRoot("RedHood_Anim", GetComponent<Animator>());
m_Animator.InitLayerAnimator(0, stateTable.Length + 3);
for (int i = 0; i < stateTable.Length; ++i)
{
stateTable[i].Init(this, i);
m_FSM.AddState(stateTable[i]);
}
m_Animator.SetLayerAnimatorActivated(0, true);
}
}
跳跃由4个动画组成:跳起、转下落、下落、落地。来看下3D角色中,根据跳跃高度与速度方向来设置4个动画的权重以混合动画的过程(可见示例中的 Robot_Jump 脚本):
private void UpdateJumpBlendByHeightAndSpeed()
{
var tf = agent.transform;
float rise = 0, riseToFall = 0, fall = 0, land = 0;
float currentY = tf.position.y;
if (isLanded)
{
land = 1;
}
else if (verticalSpeed > 0) // 垂直速度大于0,在跳起的阶段
{
// 越接近目标高度,rise 越小,riseToFall 越大
float t = Mathf.InverseLerp(jumpOriginPos.y, targetPeakY, currentY);
rise = 1 - t;
riseToFall = t;
}
else // 垂直速度小于等于0,要开始下落了
{
float heightFromPeak = targetPeakY - currentY; // 计算与跳跃最高点的高度差
// 与顶点高度差小于 peakBlendRange(可自定义该值),从 riseToFall 过渡到 fall
if (heightFromPeak < peakBlendRange)
{
float t = Mathf.InverseLerp(0, peakBlendRange, heightFromPeak);
riseToFall = 1 - t;
fall = t;
}
else // 完全下落
{
fall = 1;
}
float heightFromGround = currentY - jumpOriginPos.y; // 计算与起跳点的高度差
// 该值小于 landBlendRange (可自定义该值),从 fall 过渡到 land
if (heightFromGround < landBlendRange)
{
float t = Mathf.InverseLerp(0, landBlendRange, heightFromGround);
fall = t;
land = 1 - t;
}
}
SetJumpBlend(rise, riseToFall, fall, land); // 应用混合
}
private void SetJumpBlend(float rise, float riseToFall, float fall, float land)
{
// 归一化权重(总和=1,保证混合正确)
float total = rise + riseToFall + fall + land;
if (total > 0)
{
rise /= total;
riseToFall /= total;
fall /= total;
land /= total;
}
blendAnimator.SetInputWeight(0, rise);
blendAnimator.SetInputWeight(1, riseToFall);
blendAnimator.SetInputWeight(2, fall);
blendAnimator.SetInputWeight(3, land);
}
哪不同状态之间的动画切换呢?可以来看其基类 RobotState 中的进入状态会调用的 Enter 函数:
public virtual void Enter()
{
if(clips != null && clips.Length > 0)
{
// 这里的animator 是 PlayableLayerAnimator 类对象
animator.CrossFade(inputPort, trainsitionDuration, clips[0].length);
}
}
似乎还缺少将动画播放时间置0的函数,毕竟 CrossFade 不包含这个功能。的确,我们看下 Robot_Run 类中,对于过渡的额外处理:
public class Robot_Run : RobotState
{
public float RePlayRunTime = 1f; // 动画播放时间置0所需间隔时间
private float lastRunTime;
public override void Enter()
{
if (Time.time - lastRunTime >= RePlayRunTime)
{
blendAnimator.SetTime(0, 0);
}
lastRunTime = Time.time;
base.Enter();
}
}
可以注意到这种处理方式下,如果两次进入移动状态的间隔短的话,会从上次播放的地方继续。可以看看做与不做这个小操作导致的表现差异:
我个人喜欢第一种效果,即短暂停顿不影响移动动画的连贯。但无论是哪种,使用 Playable API 可以很方便的控制这些动画播放的细节。
对于2D角色的从闲置进入移动状态,新增了通过按键A、按键D来动态增删移动状态,逻辑也很简单(完整可见 RedHood_Idle 和 RedHoodState):
if (Input.GetKeyDown(KeyCode.A))
{
if (agent.m_FSM.StateTable.ContainsKey(typeof(RedHood_Run)))
{
agent.m_FSM.ChangeState(typeof(RedHood_Run));
}
else
{
agent.EX_RunState.Init(agent, 3); // 初始化,涉及连接
agent.m_FSM.AddState(agent.EX_RunState);
}
}
else if (Input.GetKeyDown(KeyCode.D))
{
if (agent.m_FSM.StateTable.TryGetValue(typeof(RedHood_Run), out var res))
{
res.Disconnect(); // 取消连接,同时也进行销毁
agent.m_FSM.StateTable.Remove(typeof(RedHood_Run));
}
}
……
public class RedHoodState : ScriptableObject, IFSMState
{
protected RedHoodController agent;
protected PlayableLayerAnimator animator;
protected PlayableBlendAnimator blendAnimator;
[SerializeField] protected AnimationClip[] clips;
[SerializeField] protected float trainsitionDuration;
protected int lastPlayedPort;
protected int inputPort;
public virtual void Init(RedHoodController player, int inputPort)
{
if (clips == null || clips.Length == 0)
{
return;
}
var graph = player.m_Animator.Graph;
blendAnimator = new PlayableBlendAnimator(ref graph, clips);
this.inputPort = inputPort;
agent = player;
animator = player.m_Animator.LayerAnimators[0];
graph.Connect(blendAnimator.Mixer, 0, animator.RootMixer, inputPort);
}
……
public void Disconnect()
{
var graph = blendAnimator.Mixer.GetGraph();
graph.Disconnect(animator.RootMixer, inputPort);
blendAnimator.Destroy();
}
}
尾声
个人的示例项目就不继续多说了(其实没什么太有价值的,Playable API 的使用并不算难,主要用的就是那几个函数,只是需要注意一些事项:
- 如果要彻底取消某些动画的连接,记得使用
Disconnect后还要Destroy进行销毁,否则会内存泄漏; - 不参与表现的动画要真正暂停播放,而不是简单的将其权重置0,这样能提高些性能;
- 根据具体情况,决定是否要在播放前,将动画时间置0,以便从头播放;
- 如果有动态添加动画到 Mixer 的需求,应该在初始化时预留一些输入数量(否则输入满的话添加会有访问越界报错)

浙公网安备 33010602011771号