内容简介:这是侑虎科技第504篇文章,感谢作者加菲教主供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)作者主页:最近在试做一个射击游戏的人物动画Demo,尝试使用了部分Unity的人形动画(Humanoid),以及 Playable Graph + Animation Job的功能。目前和美术同事配合,在Unity 2018.3.0f2中初步实现了空手移动和持枪瞄准的功能,在此做个小结。为简单起见,不使用Root motion,使用原地动
这是侑虎科技第504篇文章,感谢作者加菲教主供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
作者主页: https://www.jianshu.com/u/56cdb766653 ,作者也是U Sparkle活动参与者,UWA欢迎更多开发朋友加入U Sparkle开发者计划,这个舞台有你更精彩!
概述
最近在试做一个射击游戏的人物动画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 实现上面提到的这些功能?
参考资料
(2) TransformSceneHandle 和 TransformStreamHandle 的区别
文末,再次感谢加菲教主的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)
也欢迎大家来积极参与U Sparkle开发者计划,简称“US”,代表你和我,代表UWA和开发者在一起!
封面图来源: Procedural Dance Animation (舞蹈动画的程序实现实验)
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
The Zen of CSS Design
Dave Shea、Molly E. Holzschlag / Peachpit Press / 2005-2-27 / USD 44.99
Proving once and for all that standards-compliant design does not equal dull design, this inspiring tome uses examples from the landmark CSS Zen Garden site as the foundation for discussions on how to......一起来看看 《The Zen of CSS Design》 这本书的介绍吧!