文/腾讯游戏学院
9月21日,由腾讯游戏学院主办的第三届TGDC(腾讯游戏开发者大会)在深圳举行。在大会技术论坛上,无限法则主服务器程序员唐骏以无限法则的项目经历为例,分享了其在特性、物理引擎应用、移动端模拟等技术方面的开发经验。
以下为演讲实录:
射击游戏背景开发:无限法则物理引擎应用及移动端模拟
大家好,我是来自腾讯互娱北极光工作室群的,我们开发的《无限法则》是一款射击类游戏,在开发过程中,有一些创新,有一些坑,也有一些经验,想借此机会跟大家分享一下。
《无限法则》是一款PC射击游戏,于2018年9月19日在海外平台上线,英文名为Ring of,简称ROE,是一款战术竞技类游戏,但在玩法上有一些创新。
第一,我们的移动方式有很多,包括抓钩、悬挂式滑翔机等等;第二,我们用区域坍缩的方式改变物理场景,鼓励玩家到集中的区域进行更激烈的对抗;第三,我们的获胜方式不是Last Man,而是多人合作或者组队完成最后的逃生,这里面涉及到很多人之间的博弈。先走的不一定是赢家,等在后面的也不一定就是输家。
▌《无限法则》后台开发的技术难点
对于无限法则的后端开发来说,有一些关键元素是我们的技术难点。
首先,大场景中存在大量的物理破坏,玩家在射击的时候,很多固体物体都会被破坏。
第二,服务器触发瞬间大规模破坏。具体来说,就是山上发生雪崩,需要在极短的时间内摧毁大量的建筑。对我们而言,最大的挑战就是性能。众所周知,描述或者修改物理场景其实是非常消耗 CPU 的,而且服务器是服务于多个玩家的,所以我们需要提供一个非常流畅的体验环境。所以这时候我们需要保证服务器的逻辑延迟要足够小,不跳帧,也就是不卡顿。
此外,我们拥有复杂的多维流动性。
首先,我们有多种移动方式,比如滑翔伞、钩爪等。其次,我们有相对于平台的多维移动,比如大型轮船在海面上快速移动,玩家在轮船上互相射击。难点在于同步问题:我们需要保证各个客户端的位置一致,轮船本身也在快速移动,在波涛汹涌的海面上上下颠簸。一点点延迟抖动,客户端之间就会出现很大的偏差,所以我们要解决手感、准确度、容错性的问题。
▌《无限法则》中物理引擎的应用
首先从物理引擎的角度讲一下我们是怎么做的,物理场景中一个物体,不管它有多复杂,比如下图的房子,其实都是由各种几何形状组成的。
有了这些形状,我们实际上就可以拼凑出一个物理场景。具体来说,物理场景可以用以下组件构建:
- 地形信息,由相对稀疏的蓝线组成;
- 固件,我们认为不太可能被毁坏的东西,例如塔、很高的房子、仓库等;
- 可破坏的物体,例如墙壁、木桩、混凝土墩和一些木屋;
- 可移动物体,简单来说就是场景中可以自由移动的物体,例如人、车等。
有了这些基础物体,我们就可以搭建一个物理场景了。下图是我在编辑器里截的图,大概是我们真实场景的四十分之一大小。我们把这个物体放大一下,右边的小红框就是放大后的场景,可以看到里面有很多几何图形。
游戏场景包含了百万个场景,我们中间遇到了一个问题,查询的时候发现查询结果总是和预期不一致,后来我们抓取源码发现查询会失败,因为单个场景有十多万个场景。
如何解决这个问题?使用分而治之的方法将一个大问题分解成 N 个等效的小问题。因此,这里我们将一个大场景切成 N 个切片,每个切片包含的物理几何形状不超过上限,以解决场景过大的问题。
需要注意的一点是,如果一个物体恰好跨越了边界的两边,那么就需要将其存储在一个分片中。另外,如果一个查询的起点恰好跨越了两个分片,那么就需要查询两次,以保证在射线发射过程中,起点和终点不会遇到障碍物或者在真实场景中发现这些障碍物。
解决了海量物体的问题之后,就遇到了加载和销毁的问题,因为是厘米级的精度,一个场景加载大概需要18秒,销毁大概需要两三秒。
我们发出了创建房间的指令,让我继续玩,但是创建房间的时候CPU需要满负荷运行十几秒,玩家就会卡住十几秒,这简直让人无法接受。
所以解决这些问题有一些常用的方法,都是解决房间的加载和销毁,一个是使用进程池或者线程池来解决这个问题,就是我预先创建N多进程或者线程,每个进程或者线程持有一个场景池。
我们可以用类似的思想来做场景池,即加载线程不断产生物理场景,物理场景放在一个场景池中,保证场景池中始终有一定数量的可用物理场景,主线程在需要的时候再从场景池中挑选一个。
这里面有一些问题需要特别考虑,一个是资源调度模型的问题,因为加载是需要时间的,所以需要知道这个场景池或者这个进程池里需要预先分配多少个空闲的场景给后续的玩家使用。
另外一个问题是冷启动的时候,我们的系统在刚启动的时候,会有一段时间是不可用的,所以对于这些资源调度和下载的模型,需要做一些逻辑上的考虑,这方面业界也有一些标准的做法。

接下来是开发过程中遇到的另一个问题:内存消耗。我们做了一个测试,搭建了十个场景,反复重建,发现内存消耗比较稳定,但是单个场景的消耗达到了1.6G。
主要原因是我们的模型多,地图大,厘米级的精度。服务端不像客户端,客户端从原理上来讲,会用一个机制来处理这些问题,就是按需加载。但是对于服务端来说,服务端需要服务所有玩家,这样的话,我们需要把所有的场景都加载出来。最后我们单台物理机大概有120G的内存,所以只能容纳60个房间。60个房间应该没问题,但是后来又有一个新的需求,需要我们做一个训练模式。
所谓训练模式就是只有一个玩家,其余都是AI,我们单机120G只能服务60个玩家。为了解决这个问题,我们在处理可破坏物体的时候,如果要删除这个物体,一般的做法是先把它移除,再做一次模拟,再把这个物体的变化应用到物理场景中。
为什么我们需要为每个房间创建一个场景?因为玩家在不同的房间中可以破坏的东西是不同的。所以我们在做这种移除的时候,最核心、最根本的就是预期这个物体在物理场景的计算中不会有效。
那么,我们是否可以将物体标记为无效,而不改变它们的物理场景,而只改变它们的状态信息呢?换句话说,它们不会对计算造成任何阻碍。如果按照这个思路,我们可以将整个物理场景分为两个层次进行处理。静态数据是不会改变的物理场景;另一个是动态数据,它会随着房间内战斗的进展而变化,每个房间都不一样。
有了这种动静拆分,我们就分成两个线程,一个加载线程不停的生成静态物体然后默默的加载一个静态场景池;然后主线程在开房、销毁房间的时候动态绑定这个数据。这样做的好处是静态物理场景是复用的,也就是一个物理场景可以为N个房间使用,开房、销毁房间的速度很快。
也就是说,我们只需要在瞬间改变被标记的物理场景,并不需要真正删除物理世界中的物体,这是一种令人欣喜的做法。
我们来回顾一下我们对于物理场景的做法:第一是通过分片来解决海量物体的问题;第二是通过场景池来解决加载和销毁耗时的问题;第三是通过动静态分类来减少内存消耗,减少开房和关房的时间,让我们真正适应海量物体瞬间销毁的要求。
▌ 无限法则手机模拟
众所周知,射击类游戏最大的难题就是防作弊。一方面要保证体验的流畅,另一方面也要保证公平性。我们项目中的动作、动作、环境、场景等都极其复杂,所以做移动端模拟对我们来说是一个很大的挑战,需要解决精度、容错率的问题。
我们常见的做法是客户端做一个预演,服务器拿它的动作数据和移动数据在后台做一个一模一样的模拟,模拟完之后如果跟服务器模拟结果差别不大的话就通过,如果差别很大的话就拒绝,并且让客户端在拒绝的地方重新模拟。
这种情况下,服务器的性能压力就非常大,因为服务器需要模拟它的动画,引用动画的所有复杂数据;另外一个问题是状态恢复,也就是如何恢复到原来的状态。比如我的绳子射出去,撞到墙上,然后客户端会立刻快速移动到目标点。但是这时候如果服务器拒绝它的数据包,客户端就会卡在半空中,就会变得非常别扭,很难再做长距离的拖拽。
还有一点就是非刚体环境下的运动。比如说玩家在一条波涛汹涌的船上运动,船上的海浪非常高,最高可达十几米,这种情况下就是典型的非刚体运动,柔性运动。我们怎么去模拟这个呢?我们最初的想法是把这个连续的路径拆成N个离散的点,但是我们会给点添加一些额外的信息。比如说它的状态信息,它的附件信息。在真实场景中,我们先拿到附件信息去验证物理场景,看某个附件是否合适,然后根据它的姿态信息去判断连通性。
本质上我们要求客户端去同步模拟的中间数据,而不是服务端去做一些连续的计算。缺点就是流量有一定的增加,但是相比较而言,增加的并不是特别多,基本上是一个比较平衡的状态。
刚才提到了我们在复杂、起伏的海面上运动,这是一个复杂的非刚性环境,运动是柔性的。对于这个方法,我们需要保证客户端和服务器端有相同的移动仿真算法和环境。具体来说,我们的随机数是一致的,我们的时间是一致的,我们的海浪高度是用同一个算法求解的,也就是快速傅里叶变换。
还有一点就是,相对运动玩家在粗糙的船上移动的时候,我们其实做了一个坐标映射,用局部坐标和局部物理来确定船本身的运动,最后映射到全局坐标上,我们就是这样解决的,其实还是分而治之的思路,只不过是把一个横向的问题拆分成一个纵向的问题而已。
我刚才讲了,我们通过物理模拟或者算法一致性,解决了玩家之间、玩家与服务器之间的空间一致性问题,也就是说解决了大家都在同一个坐标点的问题。
但是如何保证时间的一致性呢?比如客户端C1在给服务器发送消息的时候,发生了网络拥塞,当网络拥塞的时候,第三方客户端C2看到拥塞,在很短的时间内连续接收到一连串移动数据。
那该怎么办呢?当出现数据拥堵时,第三方客户端还是原样模拟,努力追上去,如果实在追不上,就直接进行拖拽。但是这种行为的问题在于,第一方容易发生拖拽,而如果两个不同的第三方需要精准同步位置,其实很难做到。
这种情况下,我们给服务器加一个移动窗口,也就是说服务器针对这个拥塞控制做了延时隔离和延时计算,如果服务器发现已经到了一定范围,就会给客户端发消息拒绝你的包,然后你就可以做回滚了,这期间服务器拒绝所有客户端的移动,中间一段是服务器做的一个模拟延时行为。
除了上面说的解决延迟、拥塞、第三方不一致等问题,服务端的平滑窗口最重要的一点就是我们不会一直放过,而且我们根据第三方客户端不同的姿势设计不同的参数。比如对于挂机这种行为,我们宁愿放过,也不卡住。对于一般的移动行为,我们可能检查得更严格一些。所以这是为了让服务端滑动窗口能够进行平滑的操作。
接下来有几点需要强调一下,我们的物理环境非常复杂,动作也非常复杂,大家可以在图中看到有滑翔伞,有勾手等非常复杂的动作。还有一点就是因为我们在做延迟补偿,所以其实这个时候需要增加用户的速度控制,我们无法精准的控制速度,这就带来一个很明显或者说很容易发现的问题,就是甲方经常会因为容易超过边界速度而出现拖拽的情况。
这种情况下,我们把它的单帧容忍度放大,比如我们原本预期两帧之间的移动速度是5米/秒,然后我们把它放大到10米/秒,这样就保证了第一方的流畅度。然而这带来一个尴尬的问题,就是这给外挂的加速创造了一个作弊空间。
那我们怎么处理这些问题呢?就拿刚才的滑动窗口来说吧。滑动窗口一方面是为了服务器控制的平滑度,另一方面我们可以利用服务器的累计速度来做验证。比如我们计算出来的T1到TN之间的平均速度,根据每个单点的姿态,以及需要的速度等信息进行叠加,然后计算出精准的速度,在这方面就可以进行精准的验证。而且玩家的利润空间并不大。具体是这样的。
随着时间的推移,如果有人作弊,他相对于正常速度所能获得的收益会逐渐增加,当增加到一定范围时,服务器就会开始拒包,拒包之后,其速度会逐渐降低,逐渐降低到某个阈值之后,服务器才会允许其重新上传数据包。这样既解决了复杂场景下甲方的拖慢问题,也减少了外挂的作弊空间。
对于物理模拟,我们采用了离散的方法,增加一些额外的参数来解决位置移动的问题。还有一个是延迟补偿,就是服务器的滑动窗口,减少第一方的拖累。另外我们采用了累积验证的方式,精准的控制玩家的速度在一定的合理范围内,达到了防作弊的效果。
最后,回顾我们所有的做法,本质上都是在做各种平衡,无非就是用空间换时间,用时间换空间,用流量换电脑性能。简单来说,面粉多了就加水,水多了就加面粉。仅此而已,没什么特别的。
怎么知道是水太多还是面粉太多?你得先揉面团,在项目中遇到才能取舍。换句话说,不要怕弄脏手,先做就好。项目本身就是在各种有限条件下做各种平衡操作的作业,所以只能先做再想。