从游戏服务端角度分析移动同步(状态同步)
参考文章:
https://www.lfzxb.top/ow-gdc-gameplay-architecture-and-netcode/
https://zhuanlan.zhihu.com/p/544473862
对于游戏服务端来说,针对状态同步主要需要考虑的是三大模块,即校验、模拟、计算。客户端输入的任何信息都需要进行校验,避免多人游戏作弊情况的发生。客户端在进行游戏的同时,服务端也同样进行游戏模拟,只不过对于服务端来说只有数据,且数据是离散的,而客户端不仅有数据,同时还有渲染表现。计算即对模拟的过程进行计算,比如已知速度时间求速度等。
TIPS:下述示例网络层协议基于TCP,正常情况下应当使用UDP亦或KCP。下述移动同步思想主要来源于《守望先锋》网络同步方案以及Source Engine(起源引擎)网络同步方案。仅供参考。
对于以下设计主要目的是为了克服网络延迟、以及玩家作弊的问题。
针对这些问题,服务端和客户端分别会有不同的策略来处理,服务端会采用数据压缩、延迟补偿等技术,而这些技术对于客户端是不可见的。而客户端一般会采用执行预测值和平滑插值来提升游戏体验。
技术术语
- RTT,网络通讯在客户端和服务端之间传递需要花费一定的时间。一个数据包从客户端发送到服务端再返回客户端的时间为一个RTT。(这也就是说客户端的时间总是比服务端稍晚一些。此外,客户端的输入信息包在传输时也会发成延迟,所以服务端一直都在处理延时过的用户指令。还有,每一个客户端都有不同情况的网络延迟,这取决于客户端帧率、服务端帧率、以及相应的其它后台事务逻辑,这些都不是恒定的)
- Tick,对于服务端来说,只有数据,即从一个点到下一个点是瞬时的,即表现为数据离散。一个点到下一个点所经历的时间为一个Tick。如果默认情况下一个Tick为15ms,那么每秒就会发生66.666…次Tick。(实际上就是帧率,如果我们服务端的帧率为66.666…,那么意味着一秒执行66.666…次,也就是每次执行15ms,即一次Update为一个Tick。在每一个Tick中,服务器都要处理用户传递来的命令、做一步物理模拟、检测游戏规则、更新所有对象的状态。)
客户端策略
由于服务端和客户端的时间差异,将会引起逻辑上的问题,并且随着网络延迟的增高问题变得更加严重。如果所有的操作客户端都是等待服务端响应之后在处理,那么在快节奏的动作游戏中,甚至几毫秒的延迟,玩家都会有延迟感,并且对于命中判断这种问题,会造成难以击中其他玩家、难以和移动物体互动的情况。下面客户端通过两个方法来解决这个问题。一是客户端输入预测,一是平滑差值处理。
客户端输入预测
客户端在UI交互之后立即发送操作给服务端,此时,客户端不应该等待服务端的反馈,而应该立即移动,这个过程我们叫做客户端输入预测,那么客户端本地具体如果预测(我们约定本地玩家为p1)的目标点呢?我们假定客户端的输出流是稳定的(即客户端进行逻辑帧控制,按照一定的频率进行输出),那么客户端的始终总是会超前于服务器的,从客户端到服务端的时间,我们预计它大概超前半个RTT加上一个缓存帧的时长(缓存帧即多久进行一次输出处理)。也就是说,服务端收到客户端的状态改变移动同步请求时,客户端已经移动至如下位置(假定为二维平面,客户端当玩家操作UI时就立即进行响应移动)
predictionPosX += (rotationX * speed * (halfRTT + intervalTime));
predictionPosY += (rotationY * speed * (halfRTT + intervalTime));
而服务端也会根据半个RTT加上从缓冲区取出玩家状态的时间来在当前Tick模拟角色状态。
actualPosX += (rotationX * speed * (halfRTT + bufReadTime));
actualPosY += (rotationY * speed * (halfRTT + bufReadTime));
这也就是为何客户端永远领先于服务端。正因为客户端是一股脑的尽快接受玩家输入并渲染响应,尽可能地贴近现在时刻提高玩家体验。而如果还需要等待服务器回包才能响应的话,那看起来就太慢了,会让游戏变得卡顿。服务端模拟完角色状态之后,响应对该角色感兴趣的角色(包括它状态改变的角色本身)正确的位置信息。那么也就意味着,客户端根据玩家输入的预测(历史轨迹)需要使用一个队列进行存储,最终服务端的回包不是和当前角色所在状态比较,而是跟角色的历史状态比较。判断是否合法。如果与服务端模拟结果相同,那么客户端会开开心心地继续处理下一个输入。如果结果不一致,那么就是一个”预测错误“。这时简单的处理就是直接使用服务都安下发的结果直接覆盖当前客户端的结果,但是这个结果可能已经非常”旧“(相对于当前时刻的输入来讲)了,因为服务单的会包可能都是在几百毫秒之前的了。复杂的做法是使用例外一个队列记录玩家的历史输入(注意,这里是记录输入,而不是输入后的预测),当预测失败时,我们将从服务端没有进行校验的历史操作到当前操作的所有历史输入一次性打包发送给服务端(即从最后一次被服务器确认的运动状态到现在的全部输入都发送给服务端),服务端一次性模拟完所有的操作(相当于重播一遍直至追上当前时刻,也被称为缓存重放机制),返回一个最新状态给客户端,也就意味着服务端直接追上客户端,与客户端状态保持一致。这时服务端再次响应所有对该角色感兴趣的角色(包括当前状态角色)正确的状态信息,客户端为了玩家体验,会进行平滑插值处理。
平滑插值
这里不做过多简述,可查阅网上资料。
服务端策略
数据压缩
这里主要是对数据进行优化压缩,比如服务端AOI算法,具体可参考链接:https://blog.csdn.net/qq135595696/article/details/128377140
服务端滞后补偿
让我们来假设玩家在客户端时间10.5s时对某一个目标发出射击。开火的消息被打包成用户指令,并且发送给服务器。当这个数据包在网络传输,服务器还在持续模拟游戏世界的运行,相关目标可能会移动到一个完全不懂的位置上。 当10.6s时,玩家指令包到达服务器,但是此时服务器已经检测不到这个命中,尽管玩家确实瞄中了目标。这种错误,需要使用服务器端的滞后补偿来修正。
滞后补偿系统记录前1s内所有玩家的位置。如果一个玩家指令被执行,服务器将会用下面的公式来估算指令的生成时间:
指令执行时刻(客户端生成指令的时刻) = 当前服务器时刻 - 数据包传输延迟 - 客户端画面插值延时
然后,服务器将所有其他玩家(只有玩家)移回到指令执行时刻的位置。这样玩家的指令在执行的时候就可以正确瞄准了。在用户指令被处理完成后,玩家恢复到当前时刻的位置。
TIPS:由于实例插值被包含在计算公式中,如果没有开启实例插值,那么将引起意料之外的结果。
上图是监听服务器的屏幕截图,有200ms的网络延迟。红色的有效射击区表示的是目标在客户端上100ms + 平滑插值延 前的位置。从有效射击区显示的时间点开始,用户指令向服务器传送,目标也持续向左边移动。用户指令到达服务器,服务器根据估算出的指令生成时间重置目标的位置(蓝色有效射击区)。服务器运算出设计的弹道轨迹,并且检测出命中。
【额外说明】关于上图显示框体到底显示的是哪个时刻的简单计算:
服务器和客户端的有效射击区并不是完全匹配的,这是因为时间精度上差异造成。对于高速移动的物体,就算是几毫秒的差异,也会造成数英寸的错误。多人游戏中,命中检测并不是完美的像素检测,他受到Tick频率精度和运动速度的影响。
网络中延迟和滞后补偿将会带来一个矛盾,这两个看似会使得游戏世界的运行与真实情况的偏离。举例来说,你可能在进入掩体之后,仍旧被一个已经看不到人击中。这个是因为,服务器已经把你的游戏角色的有效射击区及时移动到掩体后,但是在攻击者的客户端上,你的角色仍旧是暴露在掩体外的。这个不一致的问题,在缓慢的数据包传播速度之下并不能被解决。但是事实是,你并不能意识到这个问题,因为光速(数据包的传输速度)太快了,所以所有人都可以见到一个相同的、正确的世界。