Unity 的动画图(PlayableGraph)和人形(Humanoid)动画初探

概述

最近在试做一个射击游戏的人物动画 Demo,尝试使用了部分 Unity 的人形动画(Humanoid),以及 Playable Graph + Animation Job 的功能。目前和美术同事配合,在 Unity 2018.3.0f2 中初步实现了空手移动和持枪瞄准的功能,在此做一小结。为简单起见,不使用 Root motion,使用原地动画,并将动画部分视为表现层,可以读取逻辑层提供的数据,但是不写入这些数据。

基本结构

核心类
  • 动画控制器(AnimController)类:所有动画代码的驱动者。根据动画图资产、来构建动画图,并驱动动画逻辑。将数据提供者、Transform 绑定等传入动画图实例。
  • 动画数据提供者(IAnimDataProvider) 接口:动画控制代码通过这个接口,以 key-value 的形式来读取业务逻辑设置的数据。具体的数据类可以实现这个接口,并将其交给 AnimController 来使用。
  • 动画图资产(AnimGraphAsset)基类:从动画图中的节点抽象而成的可配置的模块,运行时可以生成实例。这个做法来自 [1]。
  • 动画图实例(IAnimGraphInstance)接口:动画图资产的实例,最终由这些实例在运行时操作动画图。
  • 节点绑定集合(TransformBindingCollection)类:将骨骼或其他节点通过键值方式存放,以便 AnimGraphAsset 只依赖节点的键就能在运行时获取 Transform,而不需要依赖某个具体的 Transform 对象。

下面类图简单表示了这些类的关系:


10407328-212b218f681ec092.png
类图
逻辑数据的获取

如下 IAnimDataProvider 接口用来将数据传递给控制动画的代码。

    public interface IAnimDataProvider
    {
        float GetFloat(string key);
        float GetFloat(int keyId);
        int GetInt(string key);
        int GetInt(int keyId);
        bool GetBool(string key);
        bool GetBool(int keyId);
        int GetStateId(int stateGroupId);
        int GetStateId(string stateGroupName);
    }

使用这个接口就可以通过给定的关键字(key)去获取相应的数据,以及获取给定的一个状态机的当前状态。具体的数据类可以实现这个接口,每一帧由业务逻辑填充好数据。

为什么每个函数有两个重载版本呢?这是仿照 Animator 和 Material 中查找属性的思路,如果具体数据提供者类是以散列表(如 Dictionary)实现,其关键字可用 int 而非 string,使用的时候可以将 key 用 Animator.StringToHash 转换为 int 缓存起来,以提高性能。毕竟,求 string 的散列值比较费时。

未来还可以仿照 Animator 加入触发器类型的功能。

动画图资产和动画图实例

这部分内容可以参考 [1] 中的代码。动画图资产(AnimGraphAsset)基类继承自 ScriptableObject,用于对动画进行配置,如下:

    public abstract class AnimGraphAsset : ScriptableObject
    {
        public abstract IAnimGraphInstance CreateInstance(IAnimDataProvider animDataProvider, 
            TransformBindingCollection transformBindings,
            Animator animator, PlayableGraph playableGraph);
    }

从上面代码可以看出,它可以根据若干参数构造出 IAnimGraphInstance 的具体对象。IAnimGraphInstance 类似下面的代码:

    public interface IAnimGraphInstance
    {
        // 动画图销毁时做必要的清理。
        void Shutdown();
        
        // 设置 this 表示的动画子图的输入。
        void SetPlayableInput(int portId, Playable playable, int playablePort);

        // 获取 this 表示的动画子图的输出。
        void GetPlayableOutput(int portId, ref Playable playable, ref int playablePort);

        // 轮询。
        void Update(float deltaTime);
    }

AnimGraphAsset 的每个具体子类中,可以留配置数据字段,并且要有一个实现接口 IAnimGraphInstance 的子类用于 AnimGraphAsset.CreateInstance 返回。AnimGraphAsset 资产文件之间可以具有无环的依赖,以便 AnimController 可以在运行时,递归的创建必须的 IAnimGraphInstance 子类的实例,并将它们连成树状。

举例来说,角色四方向的移动需要一个混合节点,站立和四方向移动的混合又是根据 IAnimDataProvider 中读到的某个状态确定的。因此可以考虑一下几种 AnimGraphAsset:

  • AnimGraph_Clip:很通用很简单的节点,只是封装一个 AnimationClip 以及相应的 AnimationClipPlayable。
  • AnimGraph_Move4Dir:四方向动作融合。持有四个 AnimationClip,和一个表示移动方向角字段的关键字(用于从 IAnimDataProvder 里读移动方向角的值),并在其 AnimGraphInstance 内部类(实现 IAnimGraphInstance 接口)中实现混合或切换这四个 Clip 的逻辑。下图是一个实际用例(忽略 Working Mode 部分)。
10407328-3dacb0343c2dfaf3.png
四方向跑的动画图资产
  • AnimGraph_StateSelector(状态选择器):很通用的节点,根据一个状态关键字(用于从 IAnimDataProvider 中读取相应的状态 ID),以及每个状态对应的 AnimGraphAsset,来选择一个 AnimGraphAsset 来执行。为了平滑过渡,其中的 AnimGraphInstance 类可以实现这个渐变的过程(可以参考 [1] 中这个功能的实现方式)。下图是一个实际用例:根据 IAnimDataProvider 中的 LocomotiveState 状态来选择一个动画图资产进行播放。
10407328-1667314568b105ef.png
状态选择器动画图资产
动画控制器(AnimController)类——整个系统的中枢

AnimController 继承自 MonoBehaviour,持有数据的引用、Animator、节点绑定集合等(以便提供给 AnimGraphAsset 以及 IAnimGraphInstance),并持有一个作为根的 AnimGraphAsset。

  • 初始化时,创建 PlayableGraph 对象,调用这个根 Asset 的 CreateInstance,得到根资产对应的 IAnimGraphInstance,其中应该递归的,创建被依赖的资产的 AnimGraphInstance,设置它们的内部封装的 Playable 的输入输出。这之后,PlayableGraph 就可以开始播放了。
  • 运行时,每一个 Update 都是调用根图实例的 Update,里面递归的调用各个子节点的 Update。
  • 结束时,将 PlayableGraph 销毁,并递归调用各个图实例的 Shutdown 方法进行清理(这主要是为了清理各个图实例中可能使用的 NativeArray)。

动画图资产、实例和 Playable 的关系

设有 A, B, C 三种动画图资产类,其 .asset 文件有如下依赖关系(这种依赖关系体现在编辑器拖拽的序列化字段上,箭头方向表示持有/依赖)。

10407328-9ef0faec683da7eb.png
动画图资产 .asset 文件的依赖关系

运行时代码中,D 的 CreateInstance 方法将多态地调用 B 和 C 的 CreateInstance,后两者各自要调用 A 的 CreateInstance。因此作为 PlayableGraph 的子图,各个 IAnimGraphInstance 的关系如下所示。

10407328-f10ecbc7db0deba8.png
IAnimGraphInstance 之间的逻辑关系

这里,箭头表示的就是获取输入的来源。即 D 的输入是 B, C 的输出,B, C 的输入分别是两个 A 实例的输出。由于每个 IAnimGraphInstance 表示的是 PlayableGraph 的一部分,一般都会有一个 Playable 作为根节点(用于输出到下一级),除此可能有若干其他 Playable 以代码指定的方式连接起来。最终的 PlayableGraph 大致是下面这个样子。

10407328-c277fe1d7e43159b.png
PlayableGraph

其他动画图资产

线性连接

除了状态选择器(AnimGraph_StateSelector),目前我还照搬了 [1] 中的 AnimGraph_Stack,这是将其依赖的若干 AnimGraphAsset 线性连接,将前一个作为后一个的输入。运行的时候,就是第 i 个 AnimGraphAsset 生成的 IAnimGraphInstance 的输出(即 实现 GetOutputPlayable 方法得到的 Playable 的输出)作为第 i + 1 个 AnimGraphAsset 生成的 IAnimGraphInstance 的输入(实现 SetInputPlayable 方法)。

持枪的上下半身融合

这里尝试了运行时动态改变 Playable 之间的连接。

在角色空手的站立和四向跑融合得到结果(记为 x)之后,希望根据它所持武器,将相应的上半身动画和 x 融合。设该模块的 IAnimationGraphInstance 子类中,最终输出的 Playable 为 out(这里使用一个 AnimationLayerMixerPlayable 以便使用 AvatarMask)。将 x 的输出 Playable 连接 out 的输入端口 0,将第 种武器()的持枪动画(或者持枪动画和射击动画的选择结果)的 Playable 输出连接 out 的输入端口 。对于 的情况,设置层 (也就是输入端口 的 AvatarMask)即可。

10407328-2074385313fa4832.png
上下半身融合
目视方向和瞄准的 IK

这里分了三个阶段实现,每个阶段对应一个 AnimationScriptPlayable。

  • 阶段一:使用 Humanoid 自带的 IK 来实现目视方向的 IK。在此阶段的 Animation Job 的 ProcessAnimation 方法中,类似如下实现。
var humanStream = stream.AsHuman();
humanStream.SetLookAtPosition(targetPos);
humanStream.SetLookAtEyesWeight(EyesWeight);
humanStream.SetLookAtHeadWeight(HeadWeight);
humanStream.SetLookAtBodyWeight(BodyWeight);
humanStream.SetLookAtClampWeight(ClampWeight);
humanStream.SolveIK();
  • 阶段二:转动右肩膀,将枪的朝向指向目标点。
  • 阶段三:利用 Humanoid 自带的 IK 功能来实现左手 IK 到枪上的指定参考点(Effector)。

这个实现有几个问题:

  • 执行两次 Humanoid IK,性能还不知道如何。
  • 多次执行 Humanoid IK 还有一个问题,就是后面的执行要清空前面使用的参数。必须阶段三需要把阶段一设置过的那些权重参数都置为 0。目前我自己实现了一个扩展方法用于清理 IK 数据,但希望这件事能有更好的做法。在我的理解中,PlayableGraph 模糊了动画的 FK pass 和 IK pass,并不限制 IK 在哪里做,也不限制次数。
  • 阶段三中,如果直接使用枪上的某个子节点作为参考点,则相应 Animation Job 只能使用 TransformSceneHandle 来访问这个节点,而不能使用 TransformStreamHandle [2],因为这个节点并不在当前 Animator 控制的层次结构中。而使用 TransformSceneHandle 有一个很严重的问题,就是你在下一帧才能获取它在当前帧的坐标(或者至少是在 LateUpdate 中?),这就导致左手总是落后于枪的位置。因此,需要由动画师来将这个参考点做在人身上,或者根据已有的某个节点,配置一个局部坐标和局部旋转,计算出参考点的位置。对于后者,由于 Animation Job 中无法使用变换矩阵,所以只能(在所有节点 Scale 都是 1 的情况下)如下计算:
var effectorRot = OtherHandEffector.GetRotation(input);
var goalPos = OtherHandEffector.GetPosition(input) + effectorRot * OtherHandEffectorLocalOffset;
var goalRot = effectorRot * OtherHandEffectorLocalRotation;

其他问题

模型导入

导入模型 FBX 的时候,需要采取如下设置。


10407328-fc61f4179ea8a77a.png
模型 FBX 导入设置

此后展开模型 FBX 资产,可以看到下面有一个 Avatar 子节点。

这里有两个一个额外的问题

  • 按人形做 Rigging 会有一个 Optimize Game Objects 选项,勾选后可以不暴露任何子节点或者只暴露需要的子节点。但是在这种情况下,Animator 无法将这些子节点绑定成 TransformStreamHandle,因此在动画图更新过程中手动调整骨骼位置和旋转(如上面调整肩膀的旋转以将武器瞄准到正确方向的功能)就无法实现。因此,目前没有打开这个选项。

  • 需要点击 Configure... 按钮进入 Avatar 配置场景后,除了要检查骨骼层级结构是否映射正确,还要确定模型处于 T-pose。如果模型不在 T-pose 上,则需要在骨骼映射下方的 Pose 下拉菜单中选取 Enforce T-pose 项强制为 T-pose。不这样做会导致动画播放不正常。

10407328-0d0dd57f8050a1b4.png
强制 T-pose
动画导入

导入动画 FBX 时,上面这个 Rig 标签页就需要将 Avatar Definition 改为 Copy From Other Avatar,意为使用其他的 Avatar。选次项后将上面生成的 Avatar 子节点拖上去即可。

10407328-57b4b480b792eda7.png
动画 FBX 的 Rig 选项卡

为了使得根节点没有动画曲线,除了需要在 Animator 上去掉 Apply Root Motion 选项,对于使用了 Humanoid 导入的动画,还需要在 FBX 文件 Inspector 中,选中动画选项卡,做如下设置:


10407328-e99b70a6732530fd.png
动画 FBX 的 Animation 选项卡

如果只是在 Animator 上去掉了 Apply Root Motion,而没有做上述设置,Unity 仍然在计算时将一部分曲线算在根节点上,只是没有应用到渲染结果上,于是动画看起来会是很怪异的。

Animation Job 的可用性

实际上这是产品化问题。我们不知道 Unity 什么时候会将 Animation Job 正式推出,目前它毕竟是试验性代码,在名字空间 UnityEngine.Experimental.Animation 中。另外就是,在这个部分作为正式 API 之前,有没有一种替代方式,能结合 PlayableGraph 实现上面提到的这些功能?

参考资料

[1] Unity 官方 FPS Demo

[2] TransformSceneHandle 和 TransformStreamHandle 的区别

[3] Unity 关于 RootMotion 的官方文档

猜你喜欢

转载自blog.csdn.net/weixin_34291004/article/details/87453129
今日推荐