Unreal Open Day 2017 活动上 ,Epic Games 资深开发者支持工程师王祢先生为到场的开发者介绍了在 Unreal Engine 4 中动画系统,以下是演讲实录。
大家好!鉴于引擎移动端功能以及 UI 优化都有同事做了介绍,今天我选择讲的主题是关于动画。动画是一个非常复杂的系统,我会主要介绍一些基本的概念,大家在了解了基本概念后,就可以在上面做出扩展。我并不会教大家怎么使用动画工具,关于一些动画节点的使用,我们的在线文档上都有比较详细的说明,也有比较多的资源。今天不会讲到的内容包括 Morph target,IK,Retargeting,Rootmotion,Additive,Skeletal Control 这些。
首先,我们先来看看引擎中的动画系统是如何工作的。为什么我要先讲解这样一个问题,因为国内有很多用户在使用动画系统的时候,有很多疑问。这些疑问并不是因为他们没有查阅文档,而是因为没有理解系统的工作方式。本质上,动画系统工作原理是非常简单的,我这里还是重新介绍一下。
我们先来看看在引擎中动画相关的资源主要分为哪几类。
第一大类是最基本的数据资源。其中主要来自于外部 DCC 工具制作并导入的原始资源,我们称之为 Anim sequence。
然后,有些资源可能为了制作和导入的方便是分散开来的,但是有些情况下会组合到一起使用。所以引擎中有一种资源叫 Anim Composite。他是使用多个 Anim sequence 或是自身(Anim Composite)所组合成的资源。在使用时,依然被看作是普通的 Anim Sequence。
第三种数据资源类型叫 Blendspace。他可以是一维的也可以是二维的。二维的情况下,在两个轴上,通过变量控制对任意在二维平面上指定的动画序列(Anim sequence)作混合。对于任意的二维输入,总能找到这个输入值在二维图像附近最接近的四个动画序列按照权重来混合。严格来说,Blendspace 并不是单纯的基础数据,他也受其它输入参数的影响来混合 Pose 。但是,由于在动画混合蓝图中是作为 Pose 的输入结点,我们这里依然把它作为数据类资源。
第四种数据资源叫 Montage。这一类资源一般是直接受逻辑控制的组合资源。
在数据资源的基础上,我们还可以绑定一些额外的数据。
第一类常用的数据类型叫 Notify。引擎包括一些内建的 Notify类型。譬如,在走路的时候希望脚步踩到地面的那一刻,触发踩地面的事件,用来向地面投射贴花,用于产生脚印,以及播放脚步音效或扬起尘土的特效之类。这里的 Notify 你还可以扩展成你自定义的事件类型,可以在蓝图以及代码中去处理事件对应的逻辑。举个例子:如果做一个动作或格斗类游戏,在出招的时候,判定并不是从这个动画开始播放的时刻就已经有了的,可能是从出招动画到某一时刻开始,才有打击判定。那么我们就可以通过 Notify 来用事件通知游戏逻辑在特定的时候去打开和关闭判定。
第二类叫 Curve。Curve 就是伴随动画序列的时间轴所绑定的曲线数据,后面会有一些举例。再然后你也可以绑定一些你自定义的数据类型。
讲完刚刚这些数据类型,接下来就是最重要的处理动画混合逻辑的资源,叫 Anim instance。Anim sequence 的设计是基于对于 3A 级游戏中复杂的动画需求所产生。这里有一个假设,那就是动画状态在复杂的情况下一定是需要对骨骼结构有认知的。所以引擎中的 Anim instance 和骨骼是强耦合的关系。譬如你需要知道腰部的骨骼位置来区别开上半身和下半身的动画,这样的设计可以完成相当复杂的动画混合,但是却也带来了一些限制。如果我的整个动画状态只需要简单的一个状态机在不同的状态中,譬如闲置、追逐、攻击、受击、死亡,在每中状态中,并不作复杂的混合,而只是播放一个简单的 Anim instance。在整个逻辑中完全不需要用到骨骼信息。那么照理来说,即使拥有不同骨骼结构的对象,如果只需要这个简单逻辑的话都可以共享这套逻辑。然而由于我刚刚所说的 Anim instance 和骨骼的强耦合设计导致在现在的引擎框架下,这样的功能暂时无法完成。我们在内部也在作一些讨论,以后可能会有支持纯逻辑的 Anim instance 功能,而目前来看,如果大家有这样的需求,我建议在可能的情况下把这些对象的骨骼层次结构尽可能保持一致,这并不是说多个对象的骷髅要完全一致,而只是骨骼树的层次结构一致就可以了。譬如你的基础骨骼是个人形,有些怪物会多出尾巴或翅膀,这些多出的骨骼并不破坏原先的树状结构,而只是多出来的分支。所以还是可以利用 Retargeting 来共享 Anim instance 的逻辑。
Anim instance 中,最明显的两块分别是 EventGraph 和 AnimGraph。其中 EventGraph 就类似于普通的蓝图,用来在 tick 的时候处理一些逻辑状态的更新以及播放 Montage。当然这些逻辑也可以在 C++里面做。AnimGraph 是用来混合和输出 Pose 的地方。说到混合,我们可以把每一帧中整个混合的过程看成是一棵树,从叶子结点输出的 Pose 经过枝干结点的混合计算输出到根结点的最终 Pose。我们刚刚说到的数据类的资源,就是这里所谓的叶子结点。这些结点本身不需要其它的 Pose 作为输入,而直接提供了 Pose 的输出。而枝干结点则是进行混合的结点,当然真的说混合也不是很准确,有些枝干结点只需要输入一个 Pose,在自己的结点逻辑中,对这个 Pose 作一些修正,并不进行混合。我们把这些枝干结点计算调整和混合Pose 的行为称作评估(evaluate)。举个最简单的枝干结点的例子,那就是多结点混合。譬如,输入的有两个 Pose ,一个权重是 0.8,另一个是 0.2,相当于是把第一个 Pose 的 BoneMap 的 transform 乘以 0.8,第二个乘以 0.2,再相加输出。这里我列了一个树状图,来表示动画混合的过程。但是因为这是个非常简化了的例子,所以其中不包括直接对骨骼进行控制或者直接 Override 一个 Fullbody slot 来强制更新整个 BoneMap 之类的行为。并且一般来说,一个正常的 anim graph 的一帧的混合也不会像这张简化图这样是棵红黑树。首先,就像我刚刚说的,你并不能保证他是二叉的,譬如刚刚说的多混合结点完全可以由三个或以上结点来混合,以及我刚刚说的有些枝干结点,只有一个输入。再者,大部分情况下他也不会是平衡的。在混合状态复杂的情况下,我们一般会分层次来混合,这就导致了这棵混合树会往一个分支方向衍生出去。
好了,那么刚刚看到的是单帧的 Pose 混合计算情况。当持续到多帧以后,情况又会稍微复杂一些。譬如说两个 Pose 混合起来,他们的长度很有可能不一样。举例,我有一个走路的动画,他可能长达 2 秒,同时我又有一个跑步的动画,他长达 1 秒。如果我直接混合,就会出现很怪异的情况,譬如走路还在迈左腿的时候,跑步已经迈右腿了,混合起来的姿势就会非常奇怪。基于这种情况,我们引入了 Sync Groups 的概念,当我们设置这两个动画序列在同一个 Sync Groups 下进行混合时,引擎会把当前混合时权重较高的作为领导,把剩下的序列缩放到和领导序列一样长的情况,再按比例去做混合。这样就能解决动画长度不一致的混合问题。
再来看多帧动画状态下,如果状态复杂,动画树上的某些分支在不同的帧内是完全不同的状态。为了简化树的逻辑,动画混合系统中可以使用状态机来隔离每一帧的状态。我这里的图例举了一个比较简单的 Locomotion 的状态机。
关于动画混合的这棵树,在复杂的情况下,我们还会把他做分层。也就是把一棵混合完的树的根结点缓存下来,作为另一棵树的叶子结点。当然你也可以把整个复杂的树连到一起,分层只是为了便于维护和调整。这个图片是我们的 MOBA 游戏《虚幻争霸》中一个角色分层混合的模版示例。
讲完了动画的基础概念后,我们来看一些例子加深理解。
子树类用例。在引擎中有一类功能叫 Sub anim instance。这就类似于刚刚说到分层里面的一棵子树,这个子树可以拥有一个输入结点,并且输出一个 Pose 。典型的应用方式,是把在同一个逻辑下有多种可替换的子逻辑分离开,做到不同的 Sub anim instance 中。这样可以把剩余的逻辑用来共享。通过替换不同的 Sub anim instance 来组合出最终不同的效果。
接下来讲一些叶子类的用例。通常的叶子类结点就是我们刚刚说的数据类结点,我这里举两个比较特殊的例子。在 4.17 版本中,我们会加入一个叫 live link 的结点。它通过引擎的消息总线从外部实时读入数据输出Pose 。这里的输入源可以是各种 DCC 工具,也可以是动作捕捉或手势识别类设备。在我们放出的第一个版本中,会带有一个 maya 的实现,通过 maya 的插件把在 maya 中当前动画的 BoneMap 数据通过 live link 消息总线和引擎进行通信。引擎把接收到的数据转换成引擎内的数据输出当前的 Pose 。这样就可以做到在 maya 中一边播动画一边在引擎中看到效果了。
下一个叶子类结点的举例,叫 Pose Snapshot。Pose Snapshot 就是把任意指定帧的 BoneMap 记录下来,在接下来的任意时刻,用来作为数据源输入和其它 Pose 做混合。譬如在 Robo Recall 中,你打倒了机器人,机器人会进入物理状态而倒地。你可以把这个状态存下来,在之后再和站起来的动画作混合。
刚刚举了两个叶子类结点的例子,我们再来看看动画混合中最大的一类——枝干类结点的例子。大部分情况都是多个 Pose 按权重进行混合,当然也可以是按照 bool、int、enum 值进行混合。我这里依然举一些特殊的例子。
第一个例子是 RigidBody 结点。在讲这个结点前,我要先介绍一个伴随而来的概念,叫 immediate mode physics。引擎中以前的 Physics 是所有的 RigidBody 都加到同一个 PhysX scene,这种情况下如果每个角色身上都有多个需要计算物理的 RigidBody,场景中又有大量的这样的角色,计算量就相当的大。但是大部分时候角色互相之间的物理碰撞细节大家并不关心,所以这样的效率比较低。
因此我们和 Nvidia 进行了合作,他们对 PhysX 的 Api 进行了调整。在新版本中放出了更底层的 Api 可以让我们在引擎中做更细致的控制。大家可以看到这个新的 immediate physics,一个角色身上所有的 RigidBody 都只注册在当前这个 skeletal mesh component 下,多个 SMC(skeletal mesh component 缩写)之间并不会有交互,这样很大程度上提高了运行的效率。
大家可以看到,这里的视频同屏有几百个小兵站在地上做闲置的动画,在受到物理冲击后转入到物理状态。这么大量的物理对象在我的笔记本上依然能稳定在 60 帧,而右边的图也显示了单个较为复杂的角色在模拟物理时候的开销,只使用了 0.24ms。大家可能觉得这是一个纯粹物理的功能,为什么我放到动画的枝干结点的例子里来讲呢? 因为事实上你可以在动画中把动画计算完的 Pose 输入进去,在这个结点中根据当前动画的Pose 和前一帧计算完的结果计算出骨骼结点的变化,从而模拟出物理受力的变化,并根据输入的权重混合回你的 Pose 。有了这样的功能,做我之前说的 Robo Recall 中很自然的击倒机器人或者拳击类的游戏、以及用枪射击怪物时怪物比较自然的受击都变得相当简单。
好了,下面我们再来看另一个枝干结点的例子。我们称之为 Speed Warping。传统的游戏中如果你调整了移动速度,那么为了不产生滑步你也需要调整跑步的动画播放的速率。譬如你的速度翻了一倍,那么很多时候你就需要把动画也加快一倍播放,大家可以看到在这里的视频右边加快播放后的动画其实是很别扭的。真实情况下我们提高速度除了迈出的脚步速度会有一些变快以外,更多的情况下,其实是调快了步幅。同样的减慢速度也是这样。所以 Speed Warping 就是做了这么一个效果。那我们是怎么计算的呢?
简单来讲,原始的动画双脚的位置是这里的红球。我们计算他跟腰部垂线的水平距离并根据加减速的倍率横向扩展。譬如当是 2 倍的时候,调整到绿球的位置。但这个时候两只脚的距离被拉的太长了,因此我们适当的往下调整了屁股的位置,并且将两只脚以刚才绿球所在位置到屁股的连线上挪动一段距离使得脚步的长度保持不变,所以最终计算出来的就是蓝球的位置。
我再举一些其它的例子。比如引擎中当你对 AnimBP 进行继承的时候,所创建出来的内容叫 Child AnimBP ——它所做的事情是让你重载所有的叶子类结点。举个实用的例子:譬如我有一种敌人,他永远是从初始的出现状态到发现玩家到向玩家攻击这样转化,而这样的怪物在地图上不同的场景下有不同的出现动画,有可能是从地上爬出来的,有可能是从墙上跳出来的。对于这个怪物来说,他的动画切换状态都是一样的,所不同的只是初始状态所需要使用的资源,所以只需要替换初始的动画(某个叶子结点)就可以了。
再举一个例子,有不少人问过,在《虚幻争霸》中,是怎么做到让角色不滑步的。传统的主机游戏中,为了让脚不滑步很多时候我们都是使用 root motion 来做移动的动画。但是因为《虚幻争霸》是个 MOBA 游戏,策划会希望能够用数据来驱动移动的速度。譬如在有不同的 buff 或者装备的情况下,角色的速度也会发生变化,这用 root motion 就很不好处理。所以我们做了一个叫 Distance Curve 的功能,这也是我刚刚说到的 Curve 数据的一种运用方式。我们可以把 Distance Curve 的方式看成是反向的 root motion。它通过给所有的启动、旋转、站定动画都加入曲线数据,曲线上的数值表示当前这帧动画到达站定点的位置的距离,其中站定点(Marker)是很容易预测的。
当玩家的输入发生变化,引擎的计算在那一刻就能完成,可预测出最终速度衰减后站定的位置。通过查询曲线中动画到站定的距离可以直接从对应距离的那一帧动画开始混合。当然这些计算都有一些前提,首先,曲线中的数值在靠近站定点的动画中取负值,而远离取正值。这种时候 Piviting 行为也就是你在往左走的时候突然往右,这条曲线是从负值到正值的,这样这条曲线就满足了无论什么情况下都单调递增并且除了0其它的值都不会重复,这就方便我们在O(n)复杂度下找到对应的动画帧数。
举完了这些例子以后我们来看看动画的优化。优化是个很大的话题,有很多方面。有些是可以在设计上规避掉的,有些是则是在内容上做了优化。虽然今天我不对这些做举例,但其实引擎也有工具可以直接在骨骼结构上右键设置在某一级 LOD 以下不更新这些骨骼,这也算是内容上的优化。那么接下来我主要讲在不希望太大的妥协效果的情况下,两大类优化的手段:
其中一类就是降低人们低感知部分的采样频率。譬如空间上的 LOD 或者时间上的更新频率 URO,基本思路就是离的远的、占据视频面积小的、或者甚至是看不见的,降低更新的细节层次以及降低更新的频率。另外一类是尽可能提高利用硬件的计算能力,尽可能降低不同动画任务的依赖性来提高并行计算。
在引擎中,SkeletalMeshComponent 中有一个 Update Flag 选项,默认是 Always Tick Pose。这意味着当 SMC 不被渲染到的时候,动画逻辑还是会 Tick,并且 AnimGraph 里的节点虽然不会计算 BoneMap 也就是没有实际的 Evaluate 计算,不过还是会计算对应节点的 Update,也就是计算这些节点的输入权重之类的数值。这使得在动画对象重新进入视野中进行绘制时,可以很自然的直接更新到最新状态下的姿态。所以大部分时候,不是不得已,都不需要使用 Always Tick Pose and Referesh Bones。而如果你对于一些不太重要的动画对象,甚至不关心他们不被渲染的时候 Pose 逻辑需不需要更新的情况下,可以进一步的选择 Only Tick Pose when Rendered 来进一步减小 CPU 的开销。
另一个比较重要的设置是 AnimGraph 的各种枝干接点上的 LOD Threshold 选项,大部分这类需要进行 Evaluate Pose 计算的接点上,都会有这个选项,默认数值是 -1,也就是不会起效,如果设定了正整数值的话,就相当于在对应的 LOD 情况下,这个节点以及往下的子树就都不会评估了。对于同屏有大量骨骼动画对象的情况下,仔细调整和设置 AnimGraph 中各个节点的 LOD Threshold 能很有效的降低动画 CPU 的开销。
再有一个是刚才说道的 RigidBody 接点的 LOD 优化,引擎在创建 RigidBody 加到当前 SCM 中的时候,已经根据所有的 LOD 从最下级到最上级进行了排序,这样一来,切换 LOD 后,自然而然的只要取列表的前几项做计算就可以了。
再来我们说一下 URO,也就是更新频率的优化。例如,可以根据离 Camera 的距离,调整 Tick 的频率。我这里给了个开启 URO 的例子,甚至我们可以根据不同的 LOD 设置不同的频率,引擎中也有 LODMapForURO 的设置。
那么我们再来看看怎么提高并行。由于 BP 是在虚拟机上执行的,所以都是在游戏线程进行的,无法进行并发,所以如果有大量的动画对象希望提高并行的话,建议大家不要使用 AnimInstance 的 EventGraph 更新逻辑,而是写到 C++ 中,在自己的 AnimInstance 类中指定自己的 Proxy 继承类,并写到 Proxy 的 UpdateAnimation 中,这样引擎就能把动画的 Update 以及 Evaluate 都放到 Proxy 上通过其他工作线程并行执行。
大家可能会注意到,在 ACharacter::PostInitializeComponents() 中,对我们的 MeshComponent 的 PrimaryComponentTick 加了一个前提条件,也就是角色的 CharacterMovement 这个 component 的 tick。因为引擎希望当前这帧的动画更新的信息是基于移动后的位置进行的。如果大家不需要这样准确的依赖,还可以在自己的角色继承类中,重新去除依赖,来使得动画的计算能更早的利用工作线程并行计算。
再有一点,基于 UE4 的网络模型,服务器端在默认情况下也会有不小的动画计算开销,我们其实可以在大部分时候做一些优化,譬如关闭服务器的物理状态计算。如果不需要很精确的在服务器端计算角色的动画变化,可以保证服务端的计算不依赖于骨骼位置,那么可以在服务端完全不评估整个动画(仅使用 Capsule 作角色位置的验证或计算)。如果还能保证所有的动画中触发的事件不会影响到 Gameplay 而只会影响表现,那么还可以关闭整个动画的更新和 tick。一般来说 Montage 是游戏逻辑直接控制的动画状态,那么我们可以把在这些动画中影响游戏逻辑的事件全都加在 Montage 上,所以只有在播放 Montage 的时候才需要 tick。最后,即使进行 tick 也依然可以使用之前说到的 URO 以比较低的频率来 tick。
这里是初始化的时候设置当进行播放和停止 Montage 的时候进行回调的例子:通过判断 AnimInstance->IsAnyMontagePlaying() 来决定是不是要允许 tick,这里的事例实现了根据当前是否在播放 Montage 在服务端自动调整是不是要 tick。因为服务器端从来不需要渲染,所以当客户端设置成了 OnlyTickPoseWhenRendered 的时候,服务端就可以完全不需要 tick。
以上就是今天要讲的所有内容,谢谢大家。
本文章转自http://gad.qq.com/article/detail/27926#