Unity的动画图和人形动画初探

Unity的动画图和人形动画初探

概述

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


基本结构

1. 核心类
动画控制器(AnimController)类:所有动画代码的驱动者。根据动画图资产、来构建动画图,并驱动动画逻辑。将数据提供者、Transform绑定等传入动画图实例。

动画数据提供者(IAnimDataProvider)接口:动画控制代码通过这个接口,以key-value的形式来读取业务逻辑设置的数据。具体的数据类可以实现这个接口,并将其交给AnimController来使用。

动画图资产(AnimGraphAsset)基类:从动画图中的节点抽象而成的可配置的模块,运行时可以生成实例。这个做法来自(1)。

动画图实例(IAnimGraphInstance)接口:动画图资产的实例,最终由这些实例在运行时操作动画图。

节点绑定集合(TransformBindingCollection)类:将骨骼或其他节点通过键值方式存放,以便 AnimGraphAsset 只依赖节点的键就能在运行时获取 Transform,而不需要依赖某个具体的 Transform 对象。

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

类图
类图

2. 逻辑数据的获取
如下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 加入触发器类型的功能。

3. 动画图资产和动画图实例
这部分内容可以参考 (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 部分)。

请输入图片描述
四方向跑的动画图资产

  • AnimGraph_StateSelector(状态选择器):很通用的节点,根据一个状态关键字(用于从 IAnimDataProvider中读取相应的状态 ID),以及每个状态对应的AnimGraphAsset,来选择一个 AnimGraphAsset 来执行。为了平滑过渡,其中的AnimGraphInstance类可以实现这个渐变的过程(可以参考(1)中这个功能的实现方式)。下图是一个实际用例:根据IAnimDataProvider中的 LocomotiveState 状态来选择一个动画图资产进行播放。

请输入图片描述
状态选择器动画图资产

4.动画控制器(AnimController)类——整个系统的中枢
AnimController继承自MonoBehaviour,持有数据的引用、Animator、节点绑定集合等(以便提供给AnimGraphAsset以及IAnimGraphInstance),并持有一个作为根的AnimGraphAsset。

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

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

请输入图片描述
动画图资产.asset文件的依赖关系

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

请输入图片描述
IAnimGraphInstance之间的逻辑关系

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

请输入图片描述
PlayableGraph


其他动画图资产

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

2. 持枪的上下半身融合
这里尝试了运行时动态改变Playable之间的连接。

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

请输入图片描述
上下半身融合

3. 目视方向和瞄准的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;

其他问题

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

请输入图片描述
FBX导入设置

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

这里有两个额外的问题:

  • 按人形做Rigging会有一个Optimize Game Objects选项,勾选后可以不暴露任何子节点或者只暴露需要的子节点。但是在这种情况下,Animator无法将这些子节点绑定成TransformStreamHandle,因此在动画图更新过程中手动调整骨骼位置和旋转(如上面调整肩膀的旋转以将武器瞄准到正确方向的功能)就无法实现。因此,目前没有打开这个选项。
  • 需要点击Configure... 按钮进入Avatar配置场景后,除了要检查骨骼层级结构是否映射正确,还要确定模型处于T-pose。如果模型不在T-pose上,则需要在骨骼映射下方的Pose下拉菜单中选取Enforce T-pose项强制为T-pose。不这样做会导致动画播放不正常。

请输入图片描述
强制T-pose

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

请输入图片描述
动画FBX的Rig选项卡

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

请输入图片描述
动画FBX的Animation 选项卡

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

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


参考资料

(1) Unity 官方 FPS Demo

(2) TransformSceneHandle 和 TransformStreamHandle 的区别

(3) Unity 关于 RootMotion 的官方文档


文末,再次感谢加菲教主的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
也欢迎大家来积极参与U Sparkle开发者计划,简称“US”,代表你和我,代表UWA和开发者在一起!

这是侑虎科技第504篇文章,感谢作者加菲教主供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.jianshu.com/u/56cdb766653,作者也是U Sparkle活动参与者,UWA欢迎更多开发朋友加入U Sparkle开发者计划,这个舞台有你更精彩!

封面图来源:Procedural Dance Animation(舞蹈动画的程序实现实验)