以下为演讲实录:
大家好,我来自腾讯北极光工作室群,我们开发的《无限法则》是一款射击类的游戏,开发过程中有一些创新,也踩了一些坑,获取了一些经验,想趁这个机会分享给大家。
《无限法则》是一款射击类的端游,2018年9月19日在海外的Steam平台上线,英文名叫Ring of Elysuim, 简称ROE,属于战术竞技的游戏,但有一些玩法上的创新。
第一,我们有很多的移动方式,有抓钩、滑翔翼等;第二,我们采用区域崩塌的方式,通过改变物理场景来促使玩家到一个集中的区域进行更激烈的对抗;第三,我们并不是Last man standing的获胜方式,而是由多个人合作或者组队去完成一个最终的逃生,涉及到很多人和人之间的博弈,你先过去了未必是赢家,你在后面等着也未必是一个失败者。
一、《无限法则》后台开发技术难点
对于《无限法则》后台开发来说,有一些关键的要素,是我们的技术难点。
第一,大场景中海量的物理破坏,在玩家的射击的时候,很多的固态物件都可以被破坏掉。
第二,服务器触发瞬时大量破坏,具体来说是:山上有一个雪崩,需要在很短的时间内把大量的建筑全部摧毁掉。对我们最大的挑战是性能问题,大家知道对物理场景进行描写或者修改其实是相当消耗CPU的,而服务器是对多个玩家进行服务,我们需要提供一个非常流畅的体验环境。所以这个时候我们需要保证服务器的逻辑延迟应该足够小,不会跳帧,也就是不卡顿。
另外,我们有复杂的多维立体机动的移动方式。
第一,我们有各种各样的移动方式,有滑翔伞,有钩锁。第二,我们有相对平台的多维运动,比如,一艘很大的船在海面上快速的移动,玩家在船上进行相互的射击,这个难点在于同步的问题:我们需要确保是各个客户端位置是一致的,而船本身是在快速运动并且在一个波涛汹涌的海面上下颠簸的,一点延迟抖动就会造成客户端之间很大的偏差,所以我们解决手感、精度和误差容忍的问题。
二、《无限法则》物理引擎的应用
首先从物理引擎的方面来切入介绍一下我们是怎么做的。在物理场景中,一个物体,不管有多复杂,比如下图的房子,其实都是用各种几何形状拼接起来的。
有了这些形状以后,我们就可以真实的拼接出一个物理场景。物理场景具体来说是可以由以下几个部件来搭成的:
-
地形信息,由篮色的比较稀疏的线条组成;
-
固件,我们认为不太可能会被破坏的东西,比如说一个塔,一个很高的房子,一个仓库之类的;
-
可破坏物,一个墙体、木桩、水泥墩还有一些木头房子之类的;
-
可移动物,简单来说就是场景里面的人、车这些东西可以随便移动的。
有了这些基础物件之后,就可以搭建出一个物理场景。下图是我在编辑器截出来的,大概相当于我们真实场景的四十分之一的大小。我们把这个物件放大看,右边的小红框是放大出来的场景,可以看到几何形状是非常之多的。
在游戏场景里,包含了上百万个shapes,中间有遇到一个问题,我们查询的时候发现查询的结果总是跟预期不符,后来抓了源代码,发现是因为单场景超过十万个shapes,查询会失效。
怎么解决这个问题呢?用分治的思路,把一个大的问题拆成N个等价的小问题来解决。所以我们在这里把一个大的场景切成N个分片我们叫做Sector,每个Sector包含不超过上限的物理几何形状,通过拆分来解决场景过大的问题。
有一点需要注意,如果一个物件恰好跨在边界两边,就需要做一个分片存储,这个物件跨到哪里是就需要存储到哪里。另外,如果有一个查询的起点正好跨过了两个分片就需要查询两遍,这是为了确保在射线射出的过程中,从起点和终点在真实的场景里面不会碰到阻挡或者说找到这些阻挡。
解决了海量物件的问题以后,接下来又遇到了加载和销毁问题。因为我们是做了厘米级精度的东西,他的加载大概消耗是18秒钟,销毁一个场景大概需要两三秒钟。
我们发出指令,需要创建一个房间让我继续往下玩,但是创建的时候,需要CPU全力运行十几秒钟的时间,才能创建好,玩家会卡顿了十几秒钟,这简直是不能接受的。
所以,对于这些问题,只是单纯解决加载和销毁房间有一些比较通用的做法。一个是用进程池或者线程池的方式解决这个问题,也就是说,我预先创建了N个多进程或者线程,每个进程或者线程持有一个场景池。
我们可以用类似的思路做了一个场景池,也就是加载线程不断生产物理场景,物理场景放在一个场景池里面,保证场景池永远都有一定量的可用的物理场景,主线程在需要的时候从这个场景池里面摘一个出来。
中间有一些问题是需要特殊考虑的,一个是资源调度模型的问题,因为加载是需要时间的,需要知道这个场景池或者这个进程池里面需要有多少空闲的场景预分配出来给后面的玩家所使用。
还有一个问题是在冷启动的时候。我们的系统刚启动的时候会有一段时间不可用,所以对这些资源调度和下载模型来说,都需要进行一些逻辑上的考量,这方面业界都有一些标准的做法了。
接下来是开发过程中遇到的另一个问题,内存消耗。我们做了一个测试,建了十个场景,反复不断地重建,发现内存消耗是比较稳定的,但单一场景消耗达到1.6G。
主要是因为我们的第一是模型多,第二是地图大,第三精度达到厘米级。服务器不像客户端,客户端原则上对于这些问题它是用Streaming机制,也就是按需加载。但是对服务器来说,服务器需要服务于所有的玩家。在这种情况下我们就需要把场景全部加载出来,最终统计我们单个物理机大概在120G内存,所以它里面只能容纳60个房间。60个房间按照道理来说应该还可以,但是后来有一个新的要求,我们要做一个训练模式。
所谓训练模式,就是只有一个玩家,其它全是AI,我们单台物理机120G只能为60个玩家服务。为了解决这个问题,我们处理可破坏物的时候,如果要把这个物件给删除掉,一般的做法是把它给移除掉以后,做一个模拟update,把这个物件的改变应用到物理场景里面去。
我们为什么需要去做一个房间一个场景呢?因为玩家在不同房间中可破坏的东西是不一样的,那我们去做这种移除,最核心、最根本的是期望这个物件在物理场景的计算中不起效。
那是不是可以通过标注,把标注为失效的物件,不改变它的物理场景,只改变它的状态信息,也就是说,在这个计算里面它不会产生阻挡。如果按照这个思路,就可以把整个物理场景分成两个层级来处理。静态数据,这是个不会改变的物理场景;还有一种是动态数据,随着房间战斗的进程的变化而变化,而且每个房间是不一样的。
有了这个动静分裂之后,我们分成了两个线程,一个加载线程不断的去产生静态物件,然后静默加载一个静态的场景池;然后主线程在开启和销毁房间的时候,动态的绑定这些数据。好处是,静态的物理场景是多路复用的,也就是说,一个物理场景可以被N个房间所使用,开启房间的速度和销毁房间的速度是非常之快的。
也就是说我们只要在一瞬间改变标注的物理场景就可以了,不需要去真正的在物理世界里把这个物件给删除掉,这是一个很讨巧的做法。
再回顾一下我们的物理场景的做法:第一是分片,解决海量物件的问题;第二是用场景池来解决加载和销毁耗时的问题;第三是动静分类减少内存消耗,减少开关房间的时间消耗,也使我们真正能够容纳这种海量物件瞬间可破坏的要求。
三、《无限法则》的移动模拟
大家知道,做射击类的游戏,最大的麻烦在于反外挂。一方面我们要保证流畅的体验,另外一方面,我们也需要保障公平性。我们项目中的移动、动作、环境、场景都极为复杂,所以对我们做移动模拟,是一个很大的挑战,需要解决精确度,容忍度误差的问题。
我们常用的做法是这样的,客户端做预表现,服务器拿上它的动作数据、移动数据,在后台做一个一模一样的模拟。模拟出来以后,如果跟服务器模拟结果差距不大就通过,如果差距很大的话就拒绝它,让客户端在我拒绝的点上重新做模拟。
这种情况下,服务器的性能压力很大,因为服务器需要模拟它的动画,引用所有动画的复杂数据;另外一个是状态恢复的问题,就是Rewind的时候怎么来恢复原来状态。比如,我绳子射出去了,打到墙上去了,然后客户端就会立即的向目标点进行快速移动。但这个时候如果服务器拒绝它的包,客户端就会卡在半空中,会变得非常尴尬,导致很难去做一个长距离的拖拽。
还有一点,非刚体的环境运动。比如,一个玩家在波涛汹涌的船上运动,浪高很高,大概可以达到最高十几米。这种情况下,是一个典型的非刚体运动、柔性运动,我们怎么样去做这种模拟,我们初步的想法是把这个连续的路径拆成N个离散的点,但是在点里面我们会加入一些附加信息。比如说它的状态信息,它的附着物信息,在真实的场景中我们首先拿到了附着物信息对它进行物理场景的校验,看一个附着物是不是合适的,然后根据它的姿态信息判断一下连通性。
本质上来说,我们要求客户端来同步模拟的中间数据,而不是服务器去做一些连续运算。缺点是流量上有一定的增长,但是相比较来说,增长的量不是特别大,基本上是一种比较平衡的状态。
刚刚提到说我们在一个复杂的波涛汹涌的海面上运动,就是一个复杂的非刚体环境柔性运动。对于这种方式来说,我们要做的确保它的移动模拟的方式是客户端和服务器的算法一致,环境一致,具体来说我们是随机数是一致的,我们的时间是一致的,我们的海浪的高度是用了同样的一种算法就是快速傅里叶变化解决这个问题的。
还有一点是,相对运动玩家在波涛汹涌的船上移动的时候,我们其实做了个坐标映射。我们用局部坐标,局部物理来判断船上本身的移动行为,最终映射到全局坐标里去,用了这样的方式去解决。其实它还是一个分治的思想,只不过说是把一个水平的问题拆成了一个纵向的问题而已。
刚刚说了我们通过物理模拟的方式,或者是通过算法一致的问题解决了玩家和玩家之间,以及玩家和服务器之间的空间一致性的问题,就说是我们解决了大家在同一个坐标点上的问题。
但是时间一致性怎么来保证呢?举个例子比如说C1客户端,它发向服务器,发消息的时候,它出现了网络拥塞,在网络拥塞的时候C2也就是第三方客户端看到这个拥塞以后它的表现就是连续地、短时间会收到一串的移动数据。
要怎么做呢?在这里出现一个拥塞数据,第三方客户端还是按照原样去模拟,尽量的去追赶,如果说实在追不上了,就直接执行一个拖拽。但是这种行为会带来的问题是,第一方很容易出现拖拽,而且两个不同的第三方如果需要精确同步位置的话,实际是上难做到的。
在这种情况下,我们的做法是在服务器加了一个移动窗口,也就是说,这种拥塞控制是由服务器进行延迟隔离、延迟计算的,如果说服务器发现了它到达了一定范围之后,服务器去发rewind消息告诉客户端我拒绝你的包,然后你自己来进行一次回退。在这个期间服务器是拒绝所有客户端移动的,中间这一段就是服务器做的一个延迟的模拟行为。
服务器的平滑窗口除了解决刚说的延迟拥塞第三方不一致的问题以外,最重要的一点是我们不会连续地放过,而且根据第三方客户端的姿态的形式不一样,我们设计不一样的参数,所以比如说对钩锁这种行为,我们宁愿放过它,不要卡的状态,然后对于一般的移动行为我们可能校验会更加严格一点。所以这就是做一个服务器的滑动窗口做一个平滑的操作。
接下来有这么几点需要强调的是,我们有非常复杂的一个物理环境,我们有非常复杂的动作,大家可以看到图上有滑翔伞,钩锁什么非常复杂的动作,还有一个东西是我们因为作延迟补偿,所以在这个时候我们对用户的速度控制,其实要放大的。我们不能和精确的卡到速度,带来一个很明显,或者说很容易发现的问题,是它的第一方会经常出现拖拽。因为它很容易超过边界速度。
在这种情况下我们就把它的单帧容忍放大了,比如说它本来我们预计两帧之间它的移动速度是5米每秒,然后我们给它放大到10米每秒,这样地保证第一放方的流畅性,但是带来一个很尴尬的问题是说这个给外挂的加速带来一个作弊空间了。
对于这些问题我们怎么办?还是拿刚刚那个滑动窗口,滑动窗口我们一方面是来服务器控制平滑,另外一方面我们可以用服务器这种累加的速度来进行校验比如说我们T1和TN之间我们计算的平均速度,根据它每个单点的姿态,所需要用的速度之类的信息进行叠加,然后它算到他精确速度在这个方面就可以做到一个精算的校验。而且玩家的获利空间是不大的,具体来说是这样的。
随着时间的推移,假设有人作弊的话,他比正常速度所能获取的收益是逐渐增大的,当他增大到一定范围以后,服务器就开始拒包了,拒包了以后它的速度会逐渐地下降,逐渐地下降到一个阈值以后服务器允许它重新的上包。这样一方面解决了第一方在复杂场景下的拖拽问题,同时也压缩了外挂的作弊空间。
物理模拟我们用了离散的方式附加多带了一些附带参数来解决位置移动的问题,还有一个是延迟补偿也就是服务器的滑动窗口来减少第一方的拖拽。另外用累积校验来精确使玩家的速度控制在一定的合理范围之内,达到了反外挂的一个效果。
最后,回顾我们所有的做法,本质上都是在做各种各样的平衡,无非是拿空间换时间,拿时间换空间,拿流量来换计算机的性能。简单来说,面多了加水,水多了加面,仅此而已,没有什么特殊的地方。
怎么知道水多了还是面多了呢?你得先和面,只有在项目当中遇到了你才能去做取舍,也就是说别怕手脏,先做了再说,工程本身就是一个在各种限定的条件下做各种平衡的操作,只能先做了再说。