多人在线游戏的发展
在多人在线游戏中,为了让多名玩家能够看到相同的状态表现,服务器和客户端需要紧密结合以同步游戏世界中的对象。同步并不是服务器与生俱来的能力,需要通过一系列技术来尽量地让玩家能够看到相同的游戏世界。
查阅资料得知,最早的游戏世界使用的是P2P技术进行同步,玩家直接与玩家之间建立一个连接,不通过中间服务器。许多RTS游戏都使用P2P技术,比如:《命令与征服》、《帝国时代》、《星际争霸》等。P2P虽然很快,但是当游戏中的世界变得复杂的时候,P2P要保持对象的同步是十分困难的。想象一下,如果一个游戏对象在两台客户端上产生了不同的表现,那么模拟出来的游戏结果将截然不同。第二点就是延迟问题。如果是回合制游戏,那么同步就需要等待收到该回合内所有玩家的指令后才能进行。这样做会导致游戏的整体延迟始终等于延迟最大的那名玩家。第三点是开局前,必须保证所有玩家从相同的初始状态开始,也就是中途加入的玩家可能会导致无法同步。
随着技术演变,P2P模式被逐渐弃用,取而代之的是我们熟悉的客户端-服务器架构。客户端服务器架构下的游戏往往更加复杂,需要同步的对象也更多。所以需要考虑多方面因素,比如:客户端发送数据的可靠性、所有玩家的延迟等。
同步存在的问题
作为服务器,有个很重要的原则是永远不能相信客户端发过来的请求。所以在多人在线游戏中,服务器需要存储玩家的相关信息。以位置为例,玩家客户端按下方向键,角色开始移动,客户端告诉服务器按下的方向,服务器比较上次角色所在的坐标,并更新当前角色所处的位置,将位置回发给客户端。总而言之,游戏的状态由服务器进行管理,客户端将操作发送给服务器,服务器定期更新游戏状态,再将新的状态回发给客户端,客户端将其渲染到屏幕上。这就是状态同步的实现。
状态同步虽然能够很好地解决作弊问题,但是对于现代大型游戏来说,尤其是MMO、FPS这类对实时性要求极高的游戏,保证延迟和服务器性能的开销是十分重要的,这也是接下来我们要讨论的问题。
通常情况下,游戏的延迟在100ms以内是可以接受的,但假设玩家客户端因为某种原因造成网络拥塞,延迟直接来到了500ms,这时会发生什么?当玩家按下移动按键时,需要等待0.5秒后才能收到服务器的响应,才能看到角色的移动,这可能直接导致游戏无法继续进行了。这时客户端和服务器需要使用一系列技术来尽可能地减少延迟带来的体验丢失。我们需要引入一个很重要的概念:预测。
预测
对于较为确定的游戏对象,我们可以使用预测来提前模拟下一游戏时刻的状态。比如玩家按下移动按键后玩家的移动轨迹就是相对确定的,除非在移动时被其他实体撞了一下导致轨迹偏移。这样,我们就能预先在客户端进行模拟。假设现在玩家到服务器的延迟是100毫秒,玩家向前移动一格需要花费100毫秒。当玩家按了一次前进按键时,客户端立即更新角色进行前进操作,并渲染到屏幕上,整个操作花费200毫秒的时间。服务器在100毫秒后将收到数据,再将位于服务器上的玩家位置进行更新同步。在这种情况下,玩家的移动和服务器的响应都没有问题。但是假设现在玩家到服务器的延迟来到了250毫秒,移动一个同样花费100毫秒。现在当客户端连续按下两次前进操作时,客户端预测的状态是玩家移动了两格,但是服务器上还没来得及模拟下一个操作,玩家仅移动了一格。所以服务器返回了玩家只移动一格的数据,客户端只能按照服务器上的数据更新玩家的位置,所以此时会看到玩家走出去两格又被“拉回去”一格,最后才移动到正确的位置。如图所示。

此时的玩家体验是不佳的,我们是否有办法通过服务器的协调来解决这个问题?答案是有的。首先,我们修改一下客户端与服务器的通讯协议,在客户端的每个请求中添加一个序列号。比如第一个请求的序列号是#1,第二个则是#2,以此类推。然后当服务器回复时,也返回客户端传输的序列号。
和上一个例子的场景相同,当玩家连续按下两次移动键时,客户端发送#1、#2请求给服务器,这时,客户端先预测#1、#2的请求,玩家向前移动两格,与上一个例子不同的是,服务器收到请求后回发玩家当前位置和#1序列号给客户端,此时客户端可以更新状态并安全地销毁#1请求,但并不会立刻更新状态,因为客户端知道自己发送的第二个请求还未被服务器处理,此时客户端等待服务器发回#2后,才会安全销毁#2的状态,然后更新玩家真实的位置,这样就能够保持玩家的输入与预测的结果相同。当然,如果网络状况差到客户端始终无法收到服务器的#2请求,客户端判断超时后,玩家仍然会被上一次的状态“拉回去”。

插值
在大型多人MMO在线游戏中,服务器要实时处理同屏玩家的状态更新。但是服务器的CPU资源也是有限的,不可能以超高的频率实时更新玩家的状态。我们需要限制刷新的频率来节省资源,并且还需要让玩家看到接近实时的世界。这时,客户端插值的重要性就体现出来了。在了解插值前,先介绍一下服务器的概念:Tick和TPS。
- Tick代表游戏刻,一个游戏刻就是服务器同一时刻同时处理的游戏对象状态。
- TPS,Tick Per Second,每秒多少个游戏刻。像Minecraft这类的开放世界游戏,服务器的TPS一般设定为20,即每50毫秒刷新一次。在大型的MM在线游戏中可能更低。像我们的项目对于移动的刷新是100ms一次。在低TPS的情况下,如果不使用插值,角色会直接瞬移,导致游戏没法玩。为了让角色的移动也能够平滑化,可以使用客户端插值配合预测的方式来进行。
常见的插值方法有:导航插值(也叫航位推算)和实体插值。
导航插值
导航插值又名航位推算(Dead Remocking)。航位推算一词来源于航海导航中,在游戏中同样可以应用航位推算。导航插值适用于惯性大或者运动方向不会大幅改变的物体。比如:高速行驶的赛车和低速的船只。例如在一个赛车游戏中,赛车以100米每秒的速度行驶,汽车的位置、加速度和方向都高度依赖于其先前的位置。客户端每隔100毫秒向服务器同步一次赛车的位置。在100毫秒的时间内,客户端可以简单地假设赛车的航向和加速度保持恒定,并在本地进行物理的模拟,然后等待100毫秒收到服务器回应后,再根据服务器的回应对航向进行修复。这样赛车能够平滑地运行,而且推算的轨迹不会与实际偏差太大。但是如果这时候赛车撞到障碍物导致停车,同步就会出现问题,此时客户端应立即向服务器发送额外的同步数据包以保证状态的同步。
实体插值
在玩家的方向和速度可以立即改变的情况下,导航插值就不太好用了。例如FPS游戏,玩家射击的方向可以实时改变,身体朝向也是可以达到180度的突然转向。这时可以使用实体插值法。
在这种不太好预测的场景中,我们可以让客户端始终同步玩家过去一次Tick的状态。比如我周围的玩家在第1个Tick时位于坐标(0,10),在第2个Tick时位于坐标(0,20),则我的同步逻辑是:到了第2个Tick时,同步他在第一个Tick时的坐标,并在下一个Tick做插值运算。这样,视野内玩家的状态永远是上一个Tick时的状态,玩家的运动也是平滑的,可以解决不好预测的问题,在延迟较小的时候玩家是几乎感受不到区别的。
总结
在多人在线游戏中,同步是一个比较复杂的问题。这篇文章只是对同步的原理做一个入门的分析,实际项目的同步需要使用多种技术以及同步模型来进行,例如AOI(Area of Interest)视野同步。我会在后续的文章中进行总结。