9月21日,由腾讯游戏学院举办的第三届腾讯游戏开发者大会(以下简称TGDC)召开。在技术分论坛上,腾讯互动娱乐《无限法则》服务器主程序唐骏作了「射击游戏后台开发:《无限法则》的物理引擎应用和移动模拟」主题演讲。

腾讯互娱唐骏:《无限法则》的物理引擎应用和移动模拟-游戏价值论

以下为演讲实录,内容有删减:

我们的《无限法则》是在上海北极光工作室,这是一款射击类的游戏。在中间有了一些创新也踩了一些坑,获取了一些经验,所以想趁这个机会跟大家做一下分享。

《无限法则》是一款射击类的端游是在2018年9月19日在国外的海外的Steam上线。上线的时候名字叫Ring of Elysuim, 简称叫ROE,它是类似于Pubg或者是Fornate的模式,但是我们跟它玩法有一点区分。

第一点,我们有很多的移动方式,像图上这样的钩锁,像一个蜘蛛侠一样射到墙上人就飞过去。

第二点,我们用了区域崩塌的方式,我们改变了物理场景然后来驱赶玩家到一个集中的区域进行更激烈的对抗。

第三点,我们并不是Last man standing的获胜方式而是有多个人合作或者组队去完成一个最终的逃生,我个人感觉是蛮有乐趣的因为涉及到很多人和人之间的博弈,你先过去了未必是赢家,你在后面等着也未必是一个失败者。

有了这些东西以后,其实自己做的成绩还是相当不错的。我们在Steam同时在线最高的时候是第5名,仅次于《彩虹六号》我们也是大多数的好评。最让我们鼓舞的是国外有一个权威的评级机构叫Ign,类似于我们国内Taptap,但是它是一个权威专家式的评审,给了我们8.5分的高分。这个目前为止,据我所知在国内的端游领域我们是拿到了最高分。

知乎上有一个贴子说国内什么时候能出一款AAA游戏,我们不敢说我们是个AAA游戏,但是我们往前迈出了一步,有一点成绩可以跟父老乡亲们汇报一下。

对于后台来说有一些关键的要素,是我们后台开发的压力或者技术难度的东西。

第一点,大场景的海量的物理破坏。首先是这个海量可破坏按照我们的策划的要求,所有的物件在玩家的射击或者枪击的时候这些固态的路件都会被破坏掉。

第二点,服务器在某个时间内会触发大场景的物理破坏,具体来说是。山上有一个雪崩,下来以后在很短的时间内把大量的建筑全部摧毁掉。这个东西对我们最大的挑战是一个性能问题,因为大家知道对物理场景进行描写或者修改其实是相当消耗CPU的,而服务器是对多个玩家进行服务,我们需要提供一个非常流畅的体验环境。所以这个时候我们需要保证服务器的性能是足够小的,也就是不卡顿或者更术语话一点叫做逻辑延迟应该足够小,不会跳帧。

另外,我们有复杂的多维立体机动的方式。

1、有复杂立体的机动。我们有各种各样的移动方式,有滑翔伞,有钩锁,我刚刚举了钩锁的例子。

2、我们有相对平台的多维运动,这点比较麻烦的是说,玩家发过来的一个视频,一艘很大的船在一个海面上快速的移动。然后玩家还在船上进行相互的射击,这个难点在于它一个同步的问题,我们需要确保是各个客户端位置是一致的,而船本身是在快速运动并且它是在一个波涛汹涌的海面上上下颠簸运动的,怎么来保证用户之间看到的行为是一致的,射击精准度是有保证的,所以这点对我们造成的问题是手感、精度和误差容忍的问题。

对于这些东西来说,首先从物理引擎的方面来切入介绍一下我们是怎么做物理的。

物理引擎里面是一个房子,不管有多少复杂它都是由一堆的几何形状来构成忙,拼接起来的。右边这个图是实际在物理引擎中的一个表现,有了这些形状以后,我们就可以真实的拼接出一个物理场景。

物理场景具体来说是可以由这几个部件来搭成的。地形信息是下面的篮色的比较稀疏的线条组成的。

固件我们认为不太可能会被破坏的东西,坚固的东西比如说一个塔,一个很高的房子,一个仓库之类的。还有一些是可破坏物,我刚刚在视频里给大家展示的一个墙体,一个木桩、水泥墩还有一些木头房子之类的我们在美术上或者在策划设计上、关卡设计上把它标为可破坏物。

还有一种是可移动物,可移动物简单来说就是场景里面的人、车这些东西可以随便移动的。

有了这些基础路线之后我们搭建出一个物理场景,大家可以看到我在编辑器截出来的一个图,大概相当于我们真实场景的四十分之一的大小。我们把这个东西扩展放大一下看,这个小红框放大出来的场景大概是这样一个几何形状的描述,可以看到几何形状是非常之多的。

真正的单场景在我们的游戏世界里面包含了上百万的个shapes,中间有遇到一个问题,我们查询的时候发现查询的结果老是跟我们预期的不符,后来搜索了一下,抓了一下源代码看了一下是因为单场景超过十万个Shapes时候查询会失效的。

遇到了这个问题之后我们就在想怎么解决这个问题,咱们在计算机做程序都知道遇到一个大问题我们就分治它,我们把它拆成N个等价的小问题来解决。所以我们在这里把一个大的场景切成N个分片我们叫做Sector,每个Sector包含不超过上限的物理几何形状,通过拆分来解决场景过大的问题。

当然其中有一个点需要单独跟大家单独提出来的是,对于一个物件来说,如果它恰好跨在两边我们需要做一个分片存储,这个物件跨到哪里是就需要存储到哪里。

另外,如果有一个查询的起点正好跨过了两个分片就需要查询两遍,这是为了确保在射线的射出的过程中从起点和终点在真实的场景里面不会碰到阻挡或者说找到这些阻挡。

解决了海量物件的问题以后,接下来遇到的很尴尬的问题是加载和销毁非常耗时。因为我们是做了厘米级精度的东西,他的加载大概消耗是18秒钟,销毁一个场景大概需要两三秒钟。

我们发出指令我需要你创建一个物理场景创建一个房间让我继续往下玩,但是创建的时候,他会导致我需要cpu全力运行十几秒钟的时间才能告诉我说我创建好了,这时玩家在那儿卡顿了17秒钟,这简直是不能接受的。

还有一点跟海量物件可破坏有关系,比如说拿刚刚视频的例子来说,雪崩出来以后大概在十几秒钟的时间就会把一大片场景的物件全部破坏掉,从这里可以看到销毁占了那么长时间,那么在我做物理破坏的时候同样也会消耗这么多时间。

所以,对于这些问题,只是单纯解决加载和销毁房间有一些比较通用的做法。一个是用进程池或者线程池的方式解决这个问题,也就是说,我预先创建了N个多进程或者线程,每个进程或者线程持有一个场景池。

我们可以用类似一个思路做了一个场景池,也就是加载线程不断生产物理场景,物理场景放在一个场景池里面,保证场景池永远都有一定量的可用的物理场景,主线程在需要的时候从这个场景池里面摘一个出来。中间有一些问题是需要特殊考虑的,一个是资源调度模型的问题。资源调度模型是说我需要知道,因为我加载是需要时间我需要知道我这个场景池或者我这个进程池或者线程池里面需要有多少空闲的场景预分配出来给后面的玩家所使用。  

还有一个问题是在冷启动的时候。我们的系统刚启动的时候会有一段时间不可用,所以对这些资源调度和下载模型来说,都需要进行一些逻辑上的一些考量,但是这个东西其实业绩都有一些标准的做法,没有什么特殊的东西了。这里就不再赘述了。

刚刚提到过说我们有海量的可破坏物这种情况,而且在之前我们这个方案只是解决了它的加载和销毁的延迟问题,但是对于那种大场景的瞬间破坏怎么解决,这个就暂时先放一放,搁置到一边,我们再看一下我们接下来在开发过程中遇到的另外一个问题,是内存消耗的问题。内存消耗的时候我们做了一个测试的时候发现,我们建了十个场景,反复不断地重建,然后它的内存消耗是比较稳定的,但是有一个很麻烦的事情是单一场景消耗达到1.6g,这么个概念。

它主要是因为我们的第一是模型多,第二是地图大,第三精度是厘米级,所以这个问题其实比较麻烦的是说,因为服务器它不像客户端,客户端它是原则上对于这些问题它是用Streaming机制也就是说我按需加载。我加载了周围一块的东西,但是对服务器来说,服务器需要服务于所有的玩家。在这种情况下我们就需要把场景全部加载出来,加载出来以后,最终统计了一下我们单个物理机大概在120级g内存,所以它里面只能容纳60个房间。60个房间按照道理来说应该还可以,本来我自己想应该还可以,但是后来我们的老板提出来一个要求,是说我们要有一个训练模式。

所谓训练模式就是说一个玩家其它全部是AI在这种情况下,我们单台物理器120g这么相当好的一个物理机只能为60个玩家服务。我把这个数字报给老板的时候,老板就会对我的职业生涯产生不利的影响,这个成本就相当之高了。为了解决这个问题,所以我们在回过头来再想一下,我们处理可破坏物时候我们如果要把这个物件给删除掉,我们一般的做法是把它给移除掉,移除掉以后,做了一个模拟update把这个物件的改变应用到物理场景里面去。

我们为什么需要去做一个房间一个场景呢?就是因为我每个房间之间玩家在A房间和在B房间它所看到,或者说它的可破坏的东西是不一样的,那我们去做这种移除,最核心,最根本的是说我们期望这个物件在这个物理场景的计算中不起效。那我们是不可以做一个办法是我们标注它,我们把标注为失效的物件,我们不改变它的物理场景,我们只是改变它的状态,只是改变他的状态信息。也就是说它在这个计算里面它不不会阻挡。如果按照这个思路的时候我们就可以把整个物理场景分成两块,两个层级来处理。

一,静态数据,这是个物理场景它不会改变的场景,还有一种是我们的标注信息,这是我们认为是动态数据,动态数据是随着房间战斗的进程的变化而变化,而且每个房间是不一样的。

有了这个动静分裂之后我们就可以做到一个事,这是我们最终代码的大概结果,我们分成了两个线程,一个加载线程不断地去产生静态物件,然后静默加载一个静态的场景池。然后主线程在开启和销毁房间的时候,动态的绑定这些数据。它有一个好处是什么呢?就是说它静态的物理场景是多路复用的。也就是说我一个物理场景可以N个房间所使用,它开启房间的速度和销毁房间的速度是非常之快的,也就是可以回答刚刚那个问题,就是一瞬间破大量物件的时候是怎么做的。

也就是说我们只要在一瞬间改变它这个标注的物理场景就可以了,我们不需要去真正地在物理世界里把这个物件给删除掉,这是我们用的一个很讨巧的做法。再回顾一下我们的物理场景的做法,第一点是分片,解决海量物件的问题;第二个是用场景池来解决加载和销毁耗时的问题;第三个是动静分类减少内存消耗,减少开关房间的时间消耗,也使我们真正能够容纳这种海量物件瞬间可破坏的这种要求。

前面说的这么多,除了解决场景物件的问题,我们花了那么多经历去做物理引擎的应用和优化,还有一个很重要的原因,就是因为我们是为了做一些移动模拟,移动模拟的时候大家可以知道,最大的问题是个外挂的问题。对我们这种游戏来说有很多种移动方式。每个移动方式都不太一样,它的要求也不太一样,它的速度也不太一样,它所对于场景的需求也不太一样,在这种情况下我一方面要保证流畅的体验,另外一方面我们要保障公平性。所以我们做移动模式的时候是需要解决精确度,容忍度误差的问题。

一般的做法或者说我们常用的做法是这样的,客户端在做预表现,服务器拿上它的动作数据,它的移动数据,服务器在后台做一个一模一样的模拟。模拟出来以后如果说跟我的服务器模拟结果差距不大我就放过,如果说差距很大话,我就拒绝它,让客户端在我拒绝的点上重新做模拟,这种情况下,第一个是说显而易见的是服务器的性能压力,因为服务器需要模拟它的动画,需要引用所有的动画的复杂数据,另外一方面对我们游戏来说,最大的麻烦是在于说一个状态恢复的问题,就是Rewind的时候怎么来恢复原来状态。

举个例子来说比如说我在做钩锁的时候我们的绳子射出去了,打到墙上去了,然后我客户端就会立即地向目标点进行快速移动。但这个时候如果服务器拒绝它的包了,客户端卡在半空中,这个客户端就会变得非常地尴尬,它很难去做一个长距离地拖拽,或者说把挂在绳子上的状态恢复成原来的站立状态,这个做得比较困难。还有一点是说我们非刚体的环境运动。因为之前给大家看过一个视频,一个玩家在波涛汹涌的船上运动,是一个波涛汹涌的海面上我们的浪高很高,大概可以达到最大十几米的过渡。

在这种情况下它是一个典型的非刚体运动,柔性运动,在这种情况下,我们怎么样去做这种模拟,我们初步的想法其实也差不多,也就是把这个连续的路径拆成N个离散的点,但是在点里面我们会加入一些附加信息。比如说它的状态信息,它的附着物信息,在真实的场景中我们首先拿到了附着物信息对他进行物理场景的校验,看一个附着物是不是合法合适的。然后根据它的姿态信息判断一下连通性,如果连通性通过了以后,就可以去连续里放过。

这个实际上本质上来说我们要求客户端来同步模拟的中间数据,而不是服务器去做一些连续运算。这样的话当然它是有缺点的,它的缺点是流量上有一定的增长,但是相比较来说,它的增长的量不是特别大,基本上是出于一种比较平衡的状态。刚刚提到说我们在一个复杂的波涛汹涌的海面上运动,就是一个复杂的非刚体环境柔性运动。对于这种方式来说,我们要做的确保它的移动模拟的方式是客户端和服务器的算法一致,环境一致,具体来说我们是随机数是一致的,我们的时间是一致的,我们的海浪的高度是用了同样的一种算法就是快速傅里叶变化解决这个问题的。

还有一点是相对运动玩家在波涛汹涌的船上移动的时候,我们其实做了个坐标映射。我们用局部坐标,局部物理来判断船上本身的移动行为,最终映射到全局坐标里去,用了这样的方式去解决。其实它还是一个分治的思想,只不过说是把一个水平的问题拆成了一个纵向的问题而已。

刚刚说了我们通过物理模拟的方式,或者是通过算法一致的问题解决了玩家和玩家之间,以及玩家和服务器之间的空间一致性的问题,就说是我们能解决的问题是说大家在同一个坐标点上的问题。但是时间一致性在么来保证呢?举个例子比如说客户端C1客户端,它发向服务器,发消息的时候,它出现了网络拥塞,在网络拥塞的时候C2也就是第三方客户端看到这个拥塞以后它的表现就是连续地,短时间会收到一串的移动数据。

那这个东西怎么做呢?在这里出现一个拥塞数据,然后这个时间怎么做其实就是第三方客户端还是按照原样去模拟,尽量地去追赶,如果说实在追不上了,就直接执行一个拖拽。但是这种行为会带来什么比较大的问题是我们在实践中发现的时候它的第一方很容易出现拖拽,而且比较麻烦的是两个不同的第三方如果需要精确同步位置的话,实际是上难做到的。

在这种情况下我们做了一个做法是我们在服务器这边加了一个移动窗口,也就是说这种拥塞控制是由服务器进行延迟隔离,延迟计算的,如果说服务器这边我发现了它到达了一定范围之后,服务器去发rewind消息告诉客户端我拒绝你的包,然后你自己来进行一次回退。在这个期间服务器是拒绝所有客户端移动的,中间这一段就是服务器做的一个延迟的模拟行为。

服务器的平滑窗口除了解决刚说的延迟拥塞第三方不一致的问题以外,最重要的一点是说我们不会延续地放过,而且根据第三方客户端的姿态的形式不一样,我们设计不一样的参数,所以比如说对钩锁'这种行为,我们宁愿放过它,不要卡的状态,然后对于一般的移动行为我们可能校验会更加严格一点。所以这就是做一个服务器的滑动窗口做一个平滑的操作。

接下来有这么几点需要强调的是,我们有非常复杂的一个物理环境,我们有非常复杂的动作,大家可以看到图上有滑翔伞,钩锁什么非常复杂的动作,还有一个东西是我们因为作延迟补偿,所以在这个时候我们对用户的速度控制,其实要放大的。我们不能和精确的卡到速度,带来一个很明显,或者说很容易发现的问题,是它的第一方会经常出现拖拽。因为它很容易超过边界速度。

在这种情况下我们就把它的单帧容忍放大了,比如说它本来我们预计两帧之间它的移动速度是5米每秒,然后我们给它放大到10米每秒,这样地保证第一放方的流畅性,但是带来一个很尴尬的问题是说这个给外挂的加速带来一个作弊空间了。

对于这些问题我们怎么做呢?还是拿刚刚那个滑动窗口,滑动窗口我们一方面是来服务器控制平滑,另外一方面我们可以用服务器这种累加的速度来进行校验比如说我们T1和TN之间我们计算的平均速度,根据它每个单点的姿态,所需要用的速度之类的信息进行叠加,然后它算到他精确速度在这个方面就可以做到一个精算的校验。而且玩家的获利空间是不大的,具体来说是这样的。

随着时间的推移,假设有人作弊的话,他比正常速度所能获取的收益是逐渐增大的,当他增大到一定范围以后,服务器就开始拒包了,据包了以后它的速度会逐渐地下降,逐渐地下降到一个阈值以后服务器允许它重新的上包。这样一方面解决了第一方在复杂场景下的拖拽问题,同时也压缩了外挂的作弊空间。

物理模拟我们用了离散的方式附加多带了一些附带参数来解决位置移动的问题,还有一个是延迟补偿也就是服务器的滑动窗口来减少第一方的拖拽。另外用累积校验来精确使玩家的速度控制在一定的合理范围之内,达到了反外挂的一个效果。

最后,我们回顾我们所有的做法,其实我们本质上是说都是在做各种各样的平衡,无非是拿空间换时间,拿时间换空间,拿流量来换计算机的性能。所以简单来说,面多了加水,水多了加面,仅此而已,没有什么特殊的地方。

怎么样知道水多了还是面多了呢?其实有一点是你得先和面,这种时候只有在项目当中遇到了你才能去做取舍,也就是说别怕手脏,先做了再说,工程上本身就是一个在各种限定的条件下做各种平衡的操作,只能先做了再说。

《无限法则》是帧同步还是状态同步,物理碰撞是前端发起后端转发还是后端判定转发?

唐骏:《无限法则》是用了状态同步,帧同步在射击游戏类,大家有机会或者愿意的话可以查一下资料,帧同步在射击游戏里用的范围并不多。所以《无限法则》是用状态同步。

物理碰撞分两种,第一种是服务器主动出发物理破坏的,这种是服务器主动发起还有一种是移动化射击这种东西都是前端发起的。

场景中,那些可以变化的东西是怎么做的,比如说门并不是消失的而是旋转的。

唐骏:门本身的旋转过程你也可以把它数据离散化掉。比如说一个门它的不同姿态、不同旋转的方向你也可以做成一个东西——静态资源,只不过门有十个不同的位置下的姿态,在统一时刻起效的只有一个。

移动模拟增加了网络流量,会对网络延迟带来负面影响吗?

唐骏:其实严格来说会,还是刚刚说的那句话,我们无非是取各种平衡在空间上、时间上取各种平衡。对于这种方式其实归结于一类问题怎么做网络优化。网络优化可以有多种多样的方法,比如说可以根据距离远近大小范围做不同的优化策略,不同的广播、频度的策略控制或者再做一下流量的并包合并做一下压缩之类,总是能解决。所以回到本质问题还是在做各种各样的平衡。

服务器滑动窗口多大,怎么计算合理的大小呢?

唐骏:分成两个做法,第一步先做再说,别怕手脏。不管三七二十一根据不同的姿态先定指标再说,定了指标之后,线上并不立即开启,我们只是说先收集一波数据,根据最终的数据结果来最终统计到底有多大,这个要取决于项目本身以及移动姿态本身的问题。

最终我们根据不同的姿态、不同的尺度大小设立了不同的标准值,它不是一个唯一的参数,它是一个多个参数集合起来的参数集。