游戏网络同步算法
2022-10-20
网络同步是网络游戏中一个重要的技术问题,这里先给出一个学术性的定义:同步是指两个或两个以上随时间变化的量的变化过程中保持一定的相对关系。
下面以一个游戏例子来解释网络同步的作用:
假设游戏中A,B两个玩家移动,并同时向对方发出射击指令,如果没有合适的同步机制,
那么可能出现的情况有:
- A屏幕显示B已经被杀死,B屏幕显示A已经被杀死
- 或者在瞄准后确打不到对方
图中玩家Player1,Player2在两个不同的客户端,表现出不同效果。
同步要做的就是,让每个玩家看到的界面都是一致的。
同步有两个特性:
- 及时性
- 一致性
及时性和一致性是不能同时实现的,比如网络存在延迟,如果要求保证一致性,玩家客户端必须等待这个同步数据的到来才能进行下一步,表现就是游戏很卡;如果要求保证及时性,那么即使网络存在延迟,那么客户端也要预表现出动作,但是这个动作很可能与真实的同步数据有出入,但可以后面的表现修复减低影响。所以网络同步的及时性和一致性就在与软件设计者的trade off。
那么网络同步是具体同步什么信息?其实可以分为以下三种数据类型:
- 操作
- 事件
- 状态
根据同步的信息的不同,我们有不同的网络同步算法,这里有最为常用的帧同步和状态同步两种算法。
帧同步(lockstep)
帧同步以固定的频率同步玩家的下一帧的操作指令。这里给客户端同步的信息是操作。
我们以下面这个棋盘夺旗游戏为例,两个玩家,分别需要移动,以夺取旗子为胜利目标。
在以帧同步为同步算法时,同步的是玩家的操作,比如上面的棋盘游戏,使用帧同步同步的是这一刻玩家的动作指令(向左移动还是向上移动)。客户端以恒定的时间间隔向服务器上报操作(假设0.01秒一次上报,也就是100帧的速度tick)。那服务器是按照怎么样的一个帧率广播各个客户端的操作状态呢?因为基于一个常识:当画面以100毫秒的时间间隔进行刷新时,玩家是感知不到的刷新动作的。因此服务器下发的帧率只要比10帧快就可以了,也就是一般15~30帧的速度来广播指令。
换言之,客户端的上报速度是比服务器的广播速度要快的,所以服务器会先缓存客户端上报的帧数据,然后到达自己的tick时广播多帧给客户端。
假设A玩家向上移动了一步,在这次tick时上报了这次操作指令,服务器收到这个客户端上报后,就会做基本的检查,然后将这个A客户端的操作广播给其余客户端,B客户端也收到“A上移一步”的操作指令,在B客户端上也会表现出A上移一格的游戏状态。
客户端是由服务器发送的帧来驱动表现的,如果客户端没有收到服务器的帧,那么客户端的动画动作就会停下来,因此看来,帧同步是有很强的一致性,在各个玩家的画面看来是一致的,在高竞技性游戏下,帧同步的高一致性有很大优势。但是,网络敏感度高,一旦玩家们网络不好,画面就会卡住了。比如B客户端在t+1帧时进行了一个操作,但是他没有收到服务器给他发的t帧的操作数据(比如网络不好,延迟了),那么此时他需要一直等到这个t帧数据,才可以驱动客户端表现出t+1帧的动作,从玩家的角度看来,那就是我的游戏卡了,其实假设A客户端卡了也会有让B客户端有这个感觉,假设A上传给服务器的t帧指令延迟了,导致服务器迟迟没有给B下发t帧指令,B同样没法驱动t+1帧的表现。这个也是帧同步一个很大的缺点:一人掉线或者网络不好时,全员都需要等待无法继续游戏。当然也有办法回避这个问题,比如服务器没有收到这个客户端t帧数据,等待时间超过时,那就放弃这一帧的等待,广播当前已收到的指令,这样就不会阻塞所有游戏流程。
以Go为语言,实现帧同步的伪代码
// 客户端上报操作指令,服务器用map+链表来记录每一帧的数据
func (l *lockstep) pushCmd(cmd *pb.InputData) bool {
f, ok := l.frames[l.frameCount]
if !ok {
f = newFrameData(l.frameCount)
l.frames[l.frameCount] = f
}
f.cmds = append(f.cmds, cmd)
return true
}
// 服务器每个tick广播数据
func (g *Game) broadcastFrameData() {
...
now := time.Now().Unix()
// 取出所有客户端
for _, p := range g.players {
// 获得这个玩家已经发到哪一帧
i := p.GetSendFrameCount()
c := 0
msg := &pb.S2C_FrameMsg{}
for ; i < framesCount; i++ {
frameData := g.logic.getFrame(i)
if nil == frameData && i != (framesCount-1) {
continue
}
f := &pb.FrameData{
FrameID: proto.Uint32(i),
}
if nil != frameData {
f.Input = frameData.cmds
}
msg.Frames = append(msg.Frames, f) // 这一帧所有玩家的操作,放进list中,广播出去
c++
// 如果是最后一帧或者达到这个消息包能装下的最大帧数,就发送
if i == (framesCount-1) || c >= kMaxFrameDataPerMsg {
p.SendMessage(pb_packet.NewPacket(uint8(pb.ID_MSG_Frame), msg))
c = 0
msg = &pb.S2C_FrameMsg{}
}
}
p.SetSendFrameCount(framesCount)
}
}
同步需要保证严格的确定性,这是为了保证多个玩家客户端展示的画面是一致的。因为帧同步是对同一个世界的模拟,帧同步客户端之间传递的是操作指令,因此同样的操作指令会在不同的客户端上重新执行一次,模拟同一个世界。怎么保证不同的客户端模拟的世界是一样的呢?
同样的状态 + 同样的操作指令 = 同样的新状态。
- 保持客户端版本一致
- 谨慎使用多线程:操作系统的调度,有不确定性
- 小数的处理,保持一致:不要使用浮点数,因为在不同的硬件上浮点数的精度会不一样,因此会导致计算的状态有出入;
- 随机数处理,保持一致。游戏启动时先通过服务器同步一次随机数种子,后续各个客户端使用随机数时,随机出来的值是一致的。
帧同步一个很大的优点是做回放系统
假如一场游戏持续了20分钟,不考虑延迟的情况下整场游戏就是12000个回合(所有客户端都是如此)。现在我们反过去给每个回合添加指令,确保每个回合都收集到所有玩家的指令,那么就可以严格保证所有客户端每个回合的表现都是一样的。假如我们再把这些指令都存储起来,那么就推演出整场比赛,这也是为什么lockstep为什么做回放系统很容易。
帧同步做断线重连会存在加载时间长的缺点。中途有人掉线了,游戏就会无法继续或者掉线玩家无法重连,因为在严格的帧同步的情况下,中途加入游戏是从技术上来讲是非常困难的。因为你重新进来之后,你的初始状态和大家不一致,而且你的状态信息都是丢失状态的,比如,你的等级,随机种子,角色的属性信息等。因此玩家断线重连时,玩家载入的时间会比较久,因为此时客户端正在把过去的所有操作指令都加速播放一次,直到恢复到与其他玩家同步。
所以,总结帧同步优点就是:
- 服务器逻辑简单,负载低,服务器只是做接收和转发
- 研发周期低
- 一致性很强
- 同步流量小,带宽成本低,因为同步的是操作,承载的数据少
- 实时性更强
缺点:
- 外挂严重,因为计算逻辑都放在了客户端来做;
- 网络延时敏感度高,因为要客户端要等帧的到位才会走下一步,因此容易卡;
- 不同步的问题比较难定位和解决;
适用场景:FTG(拳王),SPG(NBA2k online),RTS(魔兽争霸),MOBA(王者),偏实时竞技类游戏
- 单局规模适中
- 不会中途加入角色
- 实时要求较高
状态同步(State Synchronization)
状态同步易上手难维护,这个算法同步的是全局状态数据(整个地图所有玩家,NPC此刻的数据)。
帧同步同步的是操作指令,而状态同步同步的是玩家的操作,比如上面的棋盘游戏,使用状态同步同步的是这一刻玩家的状态数据(位置坐标xy,或者是整个棋盘的信息)。仲裁权在服务器。
使用状态同步的服务器,可以采取2个策略来同步状态数据:
- 捕捉变更流,一旦状态有变化,服务器就广播这个状态数据;
- 固定一定tick来驱动,比如以100帧的速度驱动,服务器以0.01秒的周期向各个客户端广播状态数据。
客户端会根据自己的需求,有需要上报数据时,就直接向服务器上报。当收到服务器下发的状态同步包时,就刷新客户端的相关表现,这个就是客户端和服务器在状态同步下的处理逻辑。
这里可以看出,状态同步跟帧同步的一个区别就是,状态同步是在服务器计算好状态,再下发给各个客户端,而帧同步是客户端收到操作指令后自己计算状态,然后表现动作。那两种模式下对比可以看出,状态同步具有复杂逻辑一致性,也就是说因为各个客户端拿到的都是一样的状态数据,所以他们的客户端表现都是一致的。因为这个状态的计算是放在了服务器,所以状态同步模式下,服务器的性能消耗是比帧同步要大的。
那这个状态的一致性,是否对于每个游戏都很重要呢?首先我们先区分核心一致性和非核心一致性。对于核心一致性,我们必须把这个计算放在服务器,因为这个影响游戏进程;而非核心一致性,可以放在客户端,这个只是影响表现。
状态同步其实很适合FPS场景,FPS游戏有以下特点:
射击游戏一个很重要的一个状态判断是命中判断(核心一致性),如果我们把命中的放在客户端来计算时,每个客户端都可能有不同的判断,而且容易作弊,比如某个客户端一直说自己总是命中。这样的核心一致性,必须要服务器来仲裁,由客户端来上报射击数据,服务器来判断是否命中。
现在讨论下网络时延对状态同步的影响。因为网络时延的存在,状态同步同步的状态是过去的时间的状态,跟真实时间下的状态有一定区别。以FPS为例,怎么做命中的判断呢?我们可以使用回溯判定:
- 服务器的玩家状态位置有延迟
- 射击指令有延迟
- 服务器回溯射击发生当时的状态,进行判断
比如下图,蓝色是字段,橙色是人,此刻服务器收到的状态位置信息是这样的,人和字段没有交集,因此可以判定为射击没有命中吗?
不能,因为子弹轨迹和人的轨迹是有重合点的,因此有可能命中。此时服务器判定命中时需要回溯,把时间回调到数帧之前,计算子弹的速度和人移动的速度,逐帧推进,发现其实是可以命中的,得到正确的判断。
因为状态同步需要放在服务器计算状态,帧同步是放在客户端计算状态,为了保证核心一致性,我们采取的回溯策略,这就大大增加了服务器的计算性能。
状态同步优点
- 小规模状态/可划分子状态
- 技术门槛低
缺点:
- 大规模状态(大地图状态规模就很大)
- 流量更大
- 复杂逻辑一致性
- 后期维护成本高
适用场景:射击,赛车,三消,MMO