Unity的动画图和人形动画初探
- 作者:admin
- /
- 时间:2019年02月12日
- /
- 浏览:4156 次
- /
- 分类:厚积薄发
概述
最近在试做一个射击游戏的人物动画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 对象。
下面类图简单表示了这些类的关系:
![类图](http://uwa-ducument-img.oss-cn-beijing.aliyuncs.com/Blog/USparkle_Playable/1.jpg)
类图
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 部分)。
![请输入图片描述](http://uwa-ducument-img.oss-cn-beijing.aliyuncs.com/Blog/USparkle_Playable/2.png)
四方向跑的动画图资产
- AnimGraph_StateSelector(状态选择器):很通用的节点,根据一个状态关键字(用于从 IAnimDataProvider中读取相应的状态 ID),以及每个状态对应的AnimGraphAsset,来选择一个 AnimGraphAsset 来执行。为了平滑过渡,其中的AnimGraphInstance类可以实现这个渐变的过程(可以参考(1)中这个功能的实现方式)。下图是一个实际用例:根据IAnimDataProvider中的 LocomotiveState 状态来选择一个动画图资产进行播放。
![请输入图片描述](http://uwa-ducument-img.oss-cn-beijing.aliyuncs.com/Blog/USparkle_Playable/3.png)
状态选择器动画图资产
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文件有如下依赖关系(这种依赖关系体现在编辑器拖拽的序列化字段上,箭头方向表示持有/依赖)。
![请输入图片描述](http://uwa-ducument-img.oss-cn-beijing.aliyuncs.com/Blog/USparkle_Playable/4.png)
动画图资产.asset文件的依赖关系
运行时代码中,D的CreateInstance方法将多态地调用B和C的CreateInstance,后两者各自要调用A 的CreateInstance。因此作为PlayableGraph的子图,各个IAnimGraphInstance的关系如下所示。
![请输入图片描述](http://uwa-ducument-img.oss-cn-beijing.aliyuncs.com/Blog/USparkle_Playable/5.png)
IAnimGraphInstance之间的逻辑关系
这里,箭头表示的就是获取输入的来源。即D的输入是B,C的输出,B,C的输入分别是两个A实例的输出。由于每个IAnimGraphInstance表示的是PlayableGraph的一部分,一般都会有一个Playable作为根节点(用于输出到下一级),除此可能有若干其他Playable以代码指定的方式连接起来。最终的 PlayableGraph大致是下面这个样子。
![请输入图片描述](http://uwa-ducument-img.oss-cn-beijing.aliyuncs.com/Blog/USparkle_Playable/6.png)
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)即可。
![请输入图片描述](http://uwa-ducument-img.oss-cn-beijing.aliyuncs.com/Blog/USparkle_Playable/7.png)
上下半身融合
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的时候,需要采取如下设置。
![请输入图片描述](http://uwa-ducument-img.oss-cn-beijing.aliyuncs.com/Blog/USparkle_Playable/4.png)
FBX导入设置
此后展开模型FBX资产,可以看到下面有一个Avatar子节点。
这里有两个额外的问题:
- 按人形做Rigging会有一个Optimize Game Objects选项,勾选后可以不暴露任何子节点或者只暴露需要的子节点。但是在这种情况下,Animator无法将这些子节点绑定成TransformStreamHandle,因此在动画图更新过程中手动调整骨骼位置和旋转(如上面调整肩膀的旋转以将武器瞄准到正确方向的功能)就无法实现。因此,目前没有打开这个选项。
- 需要点击Configure... 按钮进入Avatar配置场景后,除了要检查骨骼层级结构是否映射正确,还要确定模型处于T-pose。如果模型不在T-pose上,则需要在骨骼映射下方的Pose下拉菜单中选取Enforce T-pose项强制为T-pose。不这样做会导致动画播放不正常。
![请输入图片描述](http://uwa-ducument-img.oss-cn-beijing.aliyuncs.com/Blog/USparkle_Playable/8.png)
强制T-pose
2. 动画导入
导入动画FBX时,上面这个Rig标签页就需要将Avatar Definition改为Copy From Other Avatar,意为使用其他的Avatar。选次项后将上面生成的Avatar子节点拖上去即可。
![请输入图片描述](http://uwa-ducument-img.oss-cn-beijing.aliyuncs.com/Blog/USparkle_Playable/9.png)
动画FBX的Rig选项卡
为了使得根节点没有动画曲线,除了需要在Animator上去掉Apply Root Motion选项,对于使用了 Humanoid导入的动画,还需要在FBX文件Inspector中,选中动画选项卡,做如下设置:
![请输入图片描述](http://uwa-ducument-img.oss-cn-beijing.aliyuncs.com/Blog/USparkle_Playable/10.png)
动画FBX的Animation 选项卡
如果只是在Animator上去掉了Apply Root Motion,而没有做上述设置,Unity仍然在计算时将一部分曲线算在根节点上,只是没有应用到渲染结果上,于是动画看起来会是很怪异的。
3. Animation Job的可用性
实际上这是产品化问题。我们不知道Unity什么时候会将Animation Job正式推出,目前它毕竟是试验性代码,在名字空间UnityEngine.Experimental.Animation中。另外就是,在这个部分作为正式 API 之前,有没有一种替代方式,能结合PlayableGraph 实现上面提到的这些功能?
参考资料
(2) TransformSceneHandle 和 TransformStreamHandle 的区别
文末,再次感谢加菲教主的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
也欢迎大家来积极参与U Sparkle开发者计划,简称“US”,代表你和我,代表UWA和开发者在一起!
这是侑虎科技第504篇文章,感谢作者加菲教主供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
作者主页:https://www.jianshu.com/u/56cdb766653,作者也是U Sparkle活动参与者,UWA欢迎更多开发朋友加入U Sparkle开发者计划,这个舞台有你更精彩!
封面图来源:Procedural Dance Animation(舞蹈动画的程序实现实验)