UE4实现MotionMatching(下)
上篇链接:UE4实现MotionMatching(上)
三、UE4实现
1.方案设定
我理解的MM算法主要集中与特征选择和轨迹模拟,也就是Cost函数和输入,前者决定匹配的准确率,后者决定匹配的最终效果,特征选择上直接参考Kinematica,因为效果确实挺好,轨迹模拟是这里重点需要改进的地方,最好能尽量还原人物的运动,不然总会匹配到不太理想的动作,另外还有动画数据的辅助处理,需要尽量简单并且避免对原始动画数据做修改或者增加各种Tag等工作量繁琐的操作,移动上还是走RootMotion,好处是基本不会滑步,因为如果按照荣耀战魂的思路,需要解决启动阶段容易滑步的问题,还有就是需要质量较高的动捕数据或者可以解决RootMotion速度与模拟速度不匹配的问题。
这里只实现Locomotion的MotionMatching效果,UE4使用的版本是4.25。
2.数据准备
动捕数据来自虚幻商城插件FilmstormMotionMatching,主要是急转弯、普通转弯、启动转身、跑步循环,少了一个蛇形运动,目前实现的无锁定面向的Locomotion运动,这几个也够用了,不过这里的数据还是简单了一点,具体看下图。
3.MM算法
整体参考自Kinematica,这里没有使用PQ算法,只是对原始的特征数据做了归一化,另外具体介绍一下Cost函数:
PoseDis \div PoseFetureNum \times (1-Responsiveness)+TrajectoryDis \div TrajectoryFetureNum \times Responsiveness
其中有2个魔法数:速度大小的差值乘以2,位置和位移的距离除以BondingBox的对角线长度,后者还是有效果的,应该是多次尝试出来的,可见Cost函数本身还是挺不容易设计的。
对于Kinematica里PoseFeture和TrajectoryFeture的选择,我是这样理解的,PoseFeture选的头和左右脚,头的作用主要是匹配身体的倾斜情况,避免直接从左倾切到右倾,另外Feture选了前后一帧的位移,其实直接用速度也没啥区别,因为PoseMatching都是用的预计算的匹配数据,不像TrajectoryingMatch输入的是模拟的数据,与真实的匹配数据还是有一定差别的。TrajectoryFeture用的是速度和朝向,使用速度还是相对合理,因为匹配的就是运动状态,而采样点有限的情况下使用位置是无法描述真实运动状态的,上篇的项目二中提到的问题就是TrajectoryFeture选的是位置造成的,使用朝向可以很好的匹配转身启动,例如角色面向前并向后启动,速度是直接朝后的,另外锁面向的移动方式也必须加上朝向匹配。
MM算法由于消耗较高,最好是走多线程,UE4动画更新主要是TickPose和RefreshBoneTransforms,其中大部分都可以走多线程,对应动画节点就是Update和Evaluate,不过在走RootMotion的情况下,CharacterMovement组件会先主动调用动画组件的TickPose以获取这一帧的RootMotion数据,然后在进行移动逻辑(移动组件是先Tick的),这样动画只有RefreshBoneTransforms部分可以多线程了,所以MM算法是放在动画节点的Evaluate阶段,影响就是输入会延时一帧。
4.轨迹模拟
上篇中介绍的项目都是直接用各种平滑算法来模拟轨迹的,好处就是计算简单,可以适应各种情况并能大致体现运动趋势,坏处就是跟真实运动状态并不十分符合,而且MM算法本身又是直接找到的最近邻,也就是给的轨迹是什么样就匹配什么样的,所以有些动捕的轨迹基本上是无法匹配到的:例如45°、90°急转弯,如下图所示,按照平滑算出的轨迹给出急转弯操作,匹配的是普通转弯的动画:
虽然看起来没什么太大的问题,但是毕竟里动捕是有对应的急转弯的动画的,如下图:
匹配不上90°急转弯主要是一下几点原因:
- 90°急转弯跟普通转弯在轨迹上很相似
- 90°急转弯有个较明显的减速过程然后快速转向,平滑算出的轨迹模拟不出
- 90°急转弯开始有个后仰的动作,正常跑的过程中都是身体前倾的,因此在PoseMatch的作用下会倾向与选择普通转弯的动画
解决上面的问题就不能简单的用平滑算法了,我目前的实现思路是:因为轨迹匹配的是速度,所以需要有比较精确的速度模拟,这当然还是手动用加速度来算了,不过直接控制加速度很难实现,因为运动状态很复杂,例如速度大的时候想快速转弯就比较困难,必须先减速,这里观察人物运动会发现转弯时人物会左右倾斜,加减速时人物会前后倾斜,因此定义2个状态变量对应身体的2种倾斜姿势,决定着运动公式的加速度、角加速度,输入决定目标姿势,当前姿势插值到目标姿势,急转弯需要一个先后倾在左右倾加前倾的操作序列,通过预计算得出后倾时间,这样就会得到一条大致符合人物运动规律并且变化平滑的轨迹,如下图:
可以看到轨迹已经给的挺好了,但是却一点没用,还是转弯动画,甚至还可以看到轨迹变化的时候,并没有立即切换到转弯动画,延时了几帧,主要的原因是TrajectoryFeture选的是速度,而动捕的跑步速度有很大的噪音,如下图,底部曲线是速度大小:
跑步时速度大小浮动能有20%,虽然速度方向和朝向也有浮动,但是小的多,如果将速度曲线平滑一下(Kinematica里计算速度Feture是取2帧的平均值),效果也不是很好,因为普通转弯的速度只有400多,而轨迹模拟时并没有考虑这点,进一步增加了匹配的误差。这里用了个很简单粗暴的方法:重新制作速度曲线,按照轨迹模拟的运动公式针对性的设计速度曲线,这样控制力更强一点,如下图:
这样整体轨迹匹配的准确性就大大提高了,转弯的时候,匹配的轨迹也立即跟着变了,如下图所示:
不过依然是没有实现匹配90°急转弯的动画的,其实到这也没啥好办法了,MM算法本身的局限性很难突破,也就是前面提到的PoseMatch倾向与选择普通转弯的动画,调整Responsiveness的话会影响到整体的效果,因此这里就加上了搜索的Tag,急转弯的时候只搜索特定的动画片段,虽然不太好,但是效果达到了,如下图所示:
上面的效果其实还是不太稳定,原因是动捕数据一侧的90°转弯只有一个,左右脚的PoseMatch会容易失败,所以最少还需要一个镜像的动画数据。
对于135°、180°急转弯这种的,直接平滑算法就直接可以匹配到,所以这里只是做的时候为了解决45°、90°急转弯匹配想到的一个办法,应该还是有更好的方案。
5.动画混合
MM算法里,每次切换动画帧需要进行动画混合,这属于动画的过渡混合,UE4里过渡混合默认是标准混合,也就是线性、立方、正弦、指数等各种插值函数,还有一个就是惯性化(Inertialization)混合,先直观感受下惯性化和线性混合的区别(左到右分别是:不混合、惯性化、线性混合,0.2倍速):
惯性化混合的效果感觉更好一点,过渡变化的幅度更大,能明显看到减速转身的过程。
惯性化的技术来自18年《战争机器4》的GDC分享[1],使用的是5次多项式进行过渡曲线的拟合,会计算初始的骨骼速度,并能保证加速度相对平稳的变化,大概就是保留源动画速度然后快速切换,之后缓慢过渡到目标动画。当然这里用惯性化主要是对性能提升有帮助,因为MM会导致动画较频繁的切换,传统的过渡混合会同时计算源动画和目标动画的Transform,2者根据权重变化叠加成最后的结果,而惯性化则是在请求混合的时候记录当前动画的状态以及到目标动画的差值,后面直接用多项式计算过渡动画的Transform值,效率至少提升1倍。
UE4里使用起来也十分简单,动画节点Update里在需要过渡时发送惯性过渡请求给惯性化节点,并在Evaluate时直接输出目标动画姿势就行,下图是动画蓝图:
不过这里使用惯性化还是有一个小问题,因为MM算法偶尔会在短时间内多次切换动画帧,当前的动画混合没结束又来了新的动画,传统的混合自己计算各自的权重是没啥问题的,但是UE4里惯性化节点的处理是:“An active inertialization is being interrupted. Keep track of the lost inertialization time and reduce future durations if interruptions continue.”,结果就是新的惯性化混合时间会减小,也就会看到瞬切的现象,这里可以通过CVarAnimInertializationIgnoreDeficit控制台变量临时关掉这个处理。
6.算法优化
原始的MM算法是全遍历匹配数据的,循环次数是动画总时长乘以匹配数据采样率(这里是每秒30次采样匹配数据),循环里每个特征向量需要计算距离,总体消耗还是有点高的,因此需要使用一些优化算法。这里就试了PQ和KdTree,因为只是测试性能,所以具体效果就不演示了。
Kinematica用的ProductQuantization算法消耗大概是原始的1/10,还是挺理想的,20分钟的动画数据下消耗有0.4ms(其实这里是优化了一下循环的代码,一开始写的差点要2ms),上篇介绍Kinematica里的那个分享PPT,写了消耗只有0.05ms,不知道是怎么测出来的,我简单拿UnityProfile跑了一下,Kinematica里20分钟动画数据的MM算法消耗是4.5ms,而在Brust编译下消耗大概是0.45ms,性能还是可以接受的。PQ算法的另一个优势是匹配数据占的内存更小,其实原始匹配数据20分钟也就8M,并不是很大。
KdTree本质上是个二叉查找树,而且相对比较平衡,一般是当前维数取数据的中位数来划分节点建树的,搜索起来非常快,因为使用的时候是查找最近邻,所以每次搜索的节点不一定就是离目标最近的,需要进行回溯查找确认,有时候运气好直接回溯几次就结束了,有时候需要回溯较多次,所以消耗会浮动,而且随着特征向量维数增高,回溯的次数会进一步增加,性能上会出现一定的峰值,所以需要控制特征向量维数,此外KdTree搜索时访问的节点在内存并不是连续的,而PQ的全遍历则是CPU缓存友好的。另外在MM算法里,会有一些动态搜索Tag的需求,这就破坏了原本KdTree的结构,进一步增加消耗,还有就是KdTree本身也占了点内存。
下表是简单做了下性能分析,上部分是我的测试动画数据,大概1分多钟的动画,下部分是20分钟的动画数据,其中KdTree分了特征向量60维和优化过的24维,测试环境是UE4编辑器模式,DevelopmentEditor编译模式,CPU是8核i7。
类型 | 耗时(毫秒) |
---|---|
原始 | 0.4 |
PQ | 0.03 |
KdTree | 0.06-0.15 |
KdTree_维数优化 | 0.01-0.06 |
原始_20分钟 | 6.5 |
PQ_20分钟 | 0.4 |
KdTree_20分钟 | 0.7-1.9 |
KdTree_维数优化_20分钟 | 0.1-0.7 |
可见PQ性能上应该是比KdTree稍微好点的,虽然PQ的结果是近似最近邻,但是MM算法其实并不在乎,因为本来最近邻就不一定是最好的选择,不过因为PQ量化了原始匹配数据,所以不适合数据量小的情况。
7.网络同步
由于移动走的是RootMotion,所以需要支持基于RootMotion的移动同步。首先看下UE4默认的CharacterMovementComponent里的移动同步:
- C2S是加速度、位置同步,也可以说是输入、位置同步,两边通过TimeStamp保证统一时间点计算的位置是一致的。
- 客户端每帧的移动信息数据都记录下来,服务器反馈TimeStamp的校验结果,校验失败客户端会瞬移到服务器对应TimeStamp的位置,并重新计算该TimeStamp之后的移动数据,也就相当于在新的位置上把后续的输入重新走一遍。
- S2C(模拟端)是速度、位置同步,走属性同步,客户端位置瞬移,Mesh平滑,没收到属性同步就按之前的速度本地模拟。
- 所以相关的RPC包都是UnReliable的
在看下RootMotion下的移动同步,其实只是AnimMontage的RootMotion同步:
- C2S同普通移动一样,只是在播放AnimMontage的那一帧单独走了个包,主要是让服务器知道是在哪个TimeStamp开始执行RootMotion的移动模式的。
- 客户端记录的移动数据里,增加这一帧使用的RootMotion数据,以便在重播时拿到那一帧的RootMotion的数据而不用重新从动画里获取,如果服务器的AnimMontage播放时间也与客户端不同了,会纠正客户端AnimMontage的播放时间并在移动重播中重新获取RootMotion数据。
- S2C(模拟端)不同与正常移动同步,服务器用了RootMotion专用的移动同步属性,客户端一直按自己的节奏模拟RootMotion模式下的移动,当收到服务器的的RootMotion属性同步时,也是瞬移到服务器的位置,并在AnimMontage的播放时间错误时补上这段时间的RootMotion位移数据。
- 移动同步不负责AnimMontage的播放同步,由上层负责,例如AbilitySystemComponent里就做了AnimMontage的同步。
可以看到RootMotion下的移动同步其实还是走了通用的移动同步流程,因此在普通移动同步的基础上,仿照AnimMontage只要保证两边的动画播放时间时一致的,那么各自在RootMotion移动模式下的移动结果肯定就是一致的,所以这里在客户端MM切换动画帧的时候发送一个Reliable包给服务器就行,服务器不执行MM只是校验客户端的结果,另外客户端还需重载FSavedMove_Character以记录RootMotion数据以便重播时使用。
在网络良好的情况下同步很正常,然后实际网络情况是延时加丢包,下图展示200ms延时加10%丢包的情况:
原因是单独发了个Reliable包同步动画时间,丢包在重传就是1个网络延时的来回时间也就是400ms起,中间就会有一段时间2边模拟位置不一致,也就导致了服务器频繁纠正客户端,尤其是这时候客户端又切换了新的动画帧更加剧了不同步性。
因此解决方案是跟移动同步包合并,带了上次MM切换的动画帧及时间,这样服务器永远知道精确的MM动画帧时间。
下面是200ms延时加100ms浮动加10%丢包的情况,基本没什么异常:
其实上面展示的只是较理想的情况,实际情况很可能MM连续2帧切换了动画帧,而这2帧的移动包都丢了,后续第3个包到服务器就会导致服务器后续的动画帧和客户端不一致了,这种情况其实原始的移动同步也是存在的,UE4的处理是发送ServerMoveOld,也就是冗余发送SavedMove里输入变化大的操作信息,这里就没有进一步优化了,另外移动同步里在输入改变不大的情况下会延时发送并和后面的移动包合并以节省带宽,这也是需要进一步完善的工作。
对于模拟端的移动同步,这里只是做了MM动画帧的属性同步,其他完全靠移动同步本身,后续也是要支持类似AnimMontage下RootMotion的同步处理的。
四、总结
MotionMatching虽然算法很简单,但是要想达到很好的效果还是有点难的,本文的尝试也只是实现了一个很简单的版本,而且测试数据也简单了点,虽然实现了大部分预想的效果,但还是有一些不稳定的地方,还需要在数据上做更多的工作,或者找到更好的实现方案。
MotionMatching实现的动画效果很真实,手感上也偏向于写实游戏类型,虽然适用范围有限,但是MotionMatching算法本身的PoseMatching和TrajectoryMatching的思想还是很有用的,在动画选择、动画流畅过渡上都可以起到很好的效果。
最后,本文如有错误、误导之处,烦请各位指正,谢谢!
参考
- ^Inertialization: High-Performance Animation Transitions in Gears of War https://www.gdcvault.com/play/1025165/Inertialization