4月18日至4月21日,由网易互娱学习发展举办的2022N.GAME游戏开发者峰会在线上举办。在19号的峰会上,来自网易互娱引擎部的技术专家许飞,以《构建公平的联机环境,服务器动画性能优化》为主题发表了演讲。

许飞提到,虽然现在真正使用服务器动画的游戏并不多,但这是一个比较有前景的领域,因为可以提供更公平的联机环境,而且可以实现更细致的交互。比如服务器一旦有了动画之后,服务器的角色就是活的,不再只是一堆数据,它有姿态,可以进行更复杂的交互。

我们现在提倡的云游戏也好,元宇宙也好,很难想象里面的东西是没有动画的。

以下是演讲全文(有删减):

大家好,我是来自网易互娱技术中心的许飞,很荣幸参加网易N.GAME游戏开发者峰会。本次分享的题目是《构建公平的联机环境,服务器动画性能优化》,分为四个部分:

第一部分:服务器动画的意义和现状

第二部分:服务器动画常见优化方向与方案

第三部分:如何优化复杂的动画状态机

第四部分:服务器动画展望

服务器需要跑动画吗?

在座很多有经验的开发者可能会有疑问,服务器需要跑动画吗?因为现在大多数游戏服务器是没有跑动画的。传统观点认为,动画和渲染特效一样,属于表现的层次,只要客户端看就可以了。

有限的几种需要动画来参与的逻辑,比如打击部位的判定,服务器不跑,但客户端还是有动画的。我们让客户端来进行判断,然后把结果发送给服务端,也能实现一样的效果。这种想法是正确的,也是以前常见的做法,但有个前提——你网络必须是可信任的。

但我们实际的网络环境是什么样的?

下图是Peter Steiner在1993年发布于纽约客上的一副图画,被认为揭示了互联网环境的复杂性。互联网环境非常缤纷多彩,这种多彩一方面给网络游戏的发展提供了肥沃的土壤,另一方面,它的复杂性也给游戏开发者造成了很大的挑战。

比如,我们其实并不关心玩家究竟是什么背景,有什么特点,但我们害怕的是玩家用不用外挂。对于一个交手型的游戏来说,一旦使用外挂,对游戏公平性的破坏几乎是毁灭性的。

有没有一种办法能够有效地防止或反外挂呢?一个有效的方法叫做服务器权威。

它的思路其实很简单,外挂是通过劫持游戏客户端来实现一些非法操作,但相对于玩家的客户端来讲,我们的服务器是在机房里,机房经过非常严密的保护,一般的外挂开发者很难劫持我们的服务器。

但如果把关键的逻辑都放在服务器上面,客户端仅仅作为一个指令的输入者,我们就可以防止大部分外挂的操作。

这个方法只要把客户端逻辑搬到服务器就好,很简单,但为什么现在大多数游戏没有这么做呢?

现实总是比较复杂。

看下图,这是游戏不同系统开销的展示,横坐标是游戏某个系统数据量的多少,纵坐标是属性更新频率。像等级、装备这些信息,数据量相对比较少,更新频率也相对比较低。像技能或状态之类,更新频率相对高,数据量也会相对大。

很早期的服务器,其实只会保存等级信息之类的,后来发展了一段时间之后,就开始保存装备信息、技能信息。

动画在红色的位置,具有非常庞大的数据量和非常高的更新频率,这两个轴相乘的结果才是某个功能对于算力的需求,可想而知动画对于算力的要求是非常高的。

如果我们简单地把动画从客户端挪到服务器,在没有优化的情况下,会导致服务器直接跑不起来。

这就是现实,也是为什么很多游戏没有在服务器开启动画。

还有人说,随着技术的发展,算力的价格其实在下降,CPU越来越强大,核数越来越多,技术的发展会不会让服务器的动画变得可能呢?

我收集了三个比较典型的年代:2007年、2016年和2020年,服务器每条线程成本的变化。这三年刚好也是三款典型射击游戏的发布年代。

从2007年到2016年,CPU单线程的成本大概降到了原来的1/3;从2016年到2020年,更是降到了原来的1/2。

2007年发布的《穿越火线》,服务器几乎没有跑任何动画相关的东西。2016年的《守望先锋》,它的服务器是知道动画状态的,但是只跑了一部分。什么意思呢?服务器知道这个角色,当前是跑还是跳还是释放技能。

到了2020年发布的《Volarant》,它的服务器是完全跑动画的,会完全计算角色在服务器上的状态。他们的主程在分享中也说道,这样做就是为了反外挂。因为服务器只有有了非常全面的动画信息,在判定受击的时候,才不至于被客户端的外挂所欺骗。

所以可以看到,随着技术发展,服务器动画的逻辑执行程度是越来越高的,最近一些游戏已经开始尝试在服务器上跑动画了。

业界有哪些常用的服务器动画优化方案?

既然要在服务器跑动画,那我们就要优化它的动画开销。业界常用的方法有哪些呢?

首先我们分析一下,动画系统的开销,大概分成三个部分:系统接收外界的输入,更新内部状态,最后计算出模型的姿态。

输入部分,一般就是角色速度或者说角色状态,比如在做什么。内部状态,比如角色的速度变化,可能从一个静止的状态变成跑的状态,或者从跑到跳这样的变化,最后再由这些状态计算出我的姿态——姿态就是从美术Key帧里面算出角色最终的样子。

这三部分的开销我觉得很明显:姿态更新部分的开销最大。

正如上文所说,我们评价一个功能开销,可以从它的数据量和频率两方面来计算,姿态更新为什么数据量非常大,因为每个人骨骼都有朝向、旋转和位置众多的属性,而且几乎每帧都要变化,所以姿态更新占比这么多其实并不意外,我们业界常见的优化方式也正是针对这一块进行的。

比如最简单的LOD,主要是减少了动画的数据量。服务器跑动画是为了来判断受击,但有些客户端用来表现的一些骨骼是不需要的,比如披风、头发,对于判断毫无作用,那么服务器就可以不跑,把这些剪掉就可以了。这就是服务器LOD的一种思路、一种做法。

像下图里面红色框里的骨骼,对于服务器判断受击是没有任何作用的,把它给去掉,一般可以省20-30%的开销。

除了减少数据量之外,还可以减少数据的更新频率。

基于事件的姿态更新,也就是减少姿态计算的频率。《Volarant》的技术就是这样子。一个角色只有当被击中的那一瞬间,才需要计算姿态是什么样子的,计算模型的姿态。这种优化极大地减少了姿态更新的频率。

这样做的效果非常惊人,可以把姿态更新的开销从84%直接降到9%。基本上做到这一步之后,就可以在服务器上跑动画了。可能会有一定的开销,但不至于完全跑不起来。这是业界常见的一些优化方式。

而当我们把姿态更新的开销降到9%,这时最高的开销就成了动画状态更新,占比11%,变得更凸显了。

如果一个角色很多动画逻辑非常复杂,导致状态机也非常复杂,那么开销可能还会超过11%的占比,这时候如何优化状态更新就成了当务之急。

复杂的状态机如何优化?

我们以UE为例,先来介绍状态机的样子。

首先里面有一些状态,比如说走跑跳,下图是一个最简单的Locomotion,移动的状态机,有在地面idle/walk/Run的状态,有起跳的JumpStart,在空中循环播放的JumpLoop,落地的JumpEnd的状态,它们之间有一些条件连接起来。

比如外面条件说角色下一刻要腾空了,那我就会改变一个条件,让他从idle状态跳转到JumpStart状态,起跳结束就循环播放一个空中的动作。大概就是这样的流程。

所以大概可分成三部分,第一部分叫做Find_Transitions,即从当前状态找一个可能的跳转条件。第二部分,如果这个条件为真的话,有一个跳转可能发生,那么就执行这个跳转。第三部分,执行完了之后,两个状态之间可能会有一些过渡,那还要更新这两个状态。

三个部分的开销占比是怎样的?

我们发现,寻找一个可能的跳转,和最后的状态更新,占据了绝大部分的开销。所以再来优化这部分的时候,也会选取这些开销更大的。

我们从哪些方面去优化状态机的更新?

第一,优化跳转之后两个状态的过渡。

第二,因为状态跳转每次都要计算一个条件,为真才跳转,为假就不跳转了,所以可以优化这个条件的计算。

第三,每次都要判断所有条件哪些为真,哪些为假,这个频率可以降低。还是上文所说,我们既可以降低数据量也可以降低频率。

状态过渡优化

以Locomotion为例。我选取的是在空中到落地这一段时间的状态跳转,比如原来的角色在空中,还在那里不停地播一个循环的动作,接下来他要着地了,这个时候因为两个动作之间是有一些变化的,我们不能让他瞬切,所以中间就会有一个过渡。

那这个过渡,至少有两种模式。

一种是Cross Fade,上一个状态权重逐渐降低,下一个状态权重逐渐升高,这样两个权重会出现交叉,过渡过程中两个状态权重都不为0 ,所以必须更新这两种状态。如果这两个状态中间又嵌套了别的状态机,也是一定要更新的。

第二种方式,有的引擎称它为Immediate模式,UE叫做Inertialization模式。

假如还在空中跳,下一刻要落地了,会把空中跳的状态拍个快照,然后直接就不再更新它了。接下来,下一个状态的权重逐渐从0升到1。通过这种方式,只需要更新下一个状态就ok了。这样,当状态跳转的时候,开销降了一半,因为只需要更新一个状态。

做了这样的优化之后,在Update_States状态更新这一块,会降低大概10%的开销。这是一个很好的事情,因为其实对逻辑没有影响,是一个无损的优化。

寻找跳转优化

还是以UE、从空中到落地这段时间为例。

怎么决定接下来需要着陆?一般来讲会写条件来判断,有两种写法。

下图上面的写法,直接接收了一个Bool值,Bool值如果是True就跳转。下面的写法,是写了一个表达式,通过判断速度是不是变化,来决定是否跳转。

这两种方式有什么区别呢?根据UE官方的提示,这两种方法的效率大概会相差10倍。

原因比较简单,第一种直接使用Bool值来判断的话会编译成本地代码。如果用第二种写法,编译的是虚拟机代码,经过蓝图虚拟机来执行才能判断其中的结果。

实际情况中,如果不用UE也会有一样的问题,因为这个条件也有可能不是C++写的,可能是Python来写的,那会面临一样的问题。脚本语言相比像Python、lua这样的非本地代码,性能本来就是低的。

如果我们过多地使用了不是本地化的语言写的条件,就会给状态机的更新造成很大的性能开销,那么我们可以把这部分的判断变成本地化代码,就获得了一定的性能提升。

对UE来讲,你可以用它的Nativization工具,自动化地把蓝图代码变成本地化代码,这是一个很好的工具,大家可以研究。

对于动画状态机来讲,也会带有10%左右的性能提升。

为什么优化了10倍,整体只能优化10%呢?因为那个10倍对UE来讲,仅仅是蓝图的执行和本地代码执行的开销,但是状态机是一个基于节点的结构,它的执行还是依赖节点跳转。

那就会导致一个后果,没法像优化一个语法树那样,直接把某些节点给裁剪掉。所以对于状态机这样的情况来讲,它有优化,但并没有我们想的那么高。

那还有没有更有效的优化方法?我们可以分析一下状态跳转的频率。

比如像下图左上角这个状态机,每一帧都会判断,当前状态有没有一个条件可以跳到别的状态。

像左上角这种,每一个状态只有一个指向外面的箭头,只要判断一次。对于右上角这个状态机,就比较复杂,它的idle状态的话有三条路径可以跳出去,每一帧对应的时候,就需要把三个条件全部判断一遍。

那状态机在什么时候才需要跳转呢?

有些时候,比如角色站在那儿不动,是不会跳转的,只有当玩家按下了跳跃键,或者按下了一个行走键的时候,才需要来把状态机发生跳转。而且,玩家的输入频率一般是非常低的,顶尖的电竞运动员的输入频率也就每秒钟大概7Hz,但是我们服务器的更新频率可能是每秒钟是30Hz以上。

于是我们发现,这里面有一个方法来降低跳转条件测试的频率。

其实跳转条件也是分成两类的,一类是依赖玩家的输入,这种条件我们可以把它优化掉,因为玩家的输入本来就没有那么高的频率,用一些方法来省掉它的更新就可以了。

另一种依赖于动画播放进度,我们可以优化前者的更新频率,因为玩家输入频率不高,可以直接省掉更新。具体地,可以通过在UE蓝图中进行人工标注标明可优化的跳转信息。

其实相对比较简单,写一个很快的方法来判断玩家的输入有没有发生变化,如果没变化根本不用测试,就像刚才idle突然有三条箭头冲向外面,但如果判断三个条件都没变化的话(判断Cache是不是可用的),如果Cache还是可用的话,那就直接跳过去,来省掉大部分的跳转测试开销。这个比较简单。

UE会比较复杂一点,因为UE的动画状态机是一个动画蓝图,这就意味着,相比较那些状态机纯粹是逻辑结构的情况(判断某个状态依赖的一些条件跳转相对容易一些),UE会编译成一些蓝图的字节码来执行。

当然我们可以通过编译原理的一些方法,分析蓝图的语法节点,然后来找到依赖条件。我这里还提供一个相对比较取巧的方法,这种依赖条件其实可以人工标注。

比如当前是Jump_Loop,还空中不停地循环,它依赖的跳转条件是下一个是不是已经着地了,我们把这个条件标注在这个状态上面,然后在蓝图编译的时候,利用这些标注就更容易把优化代码插进去,这样最后生成的代码就是优化后的代码。这就是一个比较简单可行的办法,也比较容易实现蓝图的优化。

但对于其他引擎来讲,如果它的状态机仅表示逻辑结构,没有像动画蓝图这么复杂,可以用更简单的办法来实现这一步跳转的优化。

这部分的优化还是挺可观的。动画状态过渡优化到了10%左右,跳转优化优化了70%。

这样就实现了比较好的效果,因为完全不影响动画的逻辑,只是让状态机跑的更快了,节省了一些不必要的计算。这就意味着服务器可以跑更多角色了,

服务器的算力没要那么高,也可以省钱。

未来:公平的环境,完备的逻辑,细致的交互

服务器动画按照现在的发展趋势,其实好处是非常多的,虽然现在真正使用服务器动画的游戏并不多,但这是一个比较有前景的领域,因为可以提供更公平的联机环境,而且可以实现更细致的交互。

比如服务器一旦有了动画之后,服务器的角色就是活的,就不只是一堆数据了,它有姿态,可以进行更复杂的交互。

我们现在提倡的云游戏也好,元宇宙也好,大概很难想象元宇宙里的东西是没有动画的。

对于服务器性能优化来讲,虽然做了这么多的工作,但其实并不彻底,因为按照我们的传统看法,动画还是基于每帧更新的,但服务器,我们觉得比较彻底的优化要完全实现动画系统的事件更新的驱动,这样,服务器只需要计算它所需要的数据就可以了。

同时,我们知道服务器虽然越来越强大,但它的性能其实还是有上限的,当服务器的性能不足以支撑这么多动画计算的时候,就需要一个动画的自动分级机制,让服务器不至于雪崩。

我觉得当这两个方向做的非常的完美了,服务器动画可能会迎来更好的发展。

本次的分享到此结束,谢谢大家。

推荐内容