在网络游戏中,网络同步方案大概有下面三种:
- 状态同步,即state synchronization
- 快照同步,即 snapshot synchronization
- 帧同步,即lock step synchronization
说帧同步,实际上这三种同步方案都是基于帧的同步,所以都可以说都是帧同步。但现在我们一般都把帧同步等同于lock step synchronization不知道是历史遗留问题呢,还是约定俗成的,就不得而知了。在这里我尝试介绍一个我了解到的一个简单帧同步方案。
客户端帧循环
在介绍方案之前,我们首先要了解网络游戏客户端是怎么运行的。可以简单的来说我们的客户端游戏逻辑运行在一个循环里,我们一般称它为帧循环。帧循环可以简单描述做了如下的事情:
- 采样玩家的输入数据(如鼠标,键盘,摇杆等状态)
- 将玩家输入数据组装数据包并通过网络调用发送给服务器
- 读取服务器从网络发过来的数据包,并调用相应的处理逻辑,更新游戏状态
- 根据当前的游戏状态渲染游戏场景
假如我们的客户端帧循环有这样一个特性:初始条件相同,输入也相同,最终得到的游戏状态那也一定相同的话(用英文表达就是客户端是deterministic的),那么我们就想在同步多个客户端的时候只要保证初始条件是相同的,那么只要把各个客户端的输入通过服务器发送给各个客户端就能达到游戏状态的最终同步了。这个就是所谓的帧同步了。其实,这里有一个隐含的条件:当一个客户端从服务器收到了另一个客户端的某个输入后,它要处理这个输入时,它当前的状态必须是这个输入发送时的状态。换句话说,这个时候要处理这个输入的客户端必须是已经把这个输入发送前收到的输入都执行完了。这个就是所谓的lockstep。我们总结一下网络帧同步的实现要做到以下两点:
- 客户端的逻辑是deterministic的
- 客户端按lockstep的方式处理来自服务器的输入数据包
这个时候我们就只需要通过服务器同步各个客户端的输入就能达到游戏状态的最终同步。
帧号
为了客户端按lockstep的方式执行收到的输入,我们在客户端帧循环的第2步,即将玩家输入组装称数据包这一步,在数据包上加上一个帧号(即Sequence Number)。当收到数据包时,我们要对比当前客户端的帧号和数据包的帧号,只有二者是一样的情况下才会立即处理该数据包。
客户端如何发送帧数据
在这里我们约定客户端按每秒10帧的频率发送数据。客户端是发送帧数据的逻辑可以简单用以下的伪代码来表示:
SendFrameData(){
// 距上次发送数据的时间小于100ms
if (elapsedTime < 100){
return;
}
// 帧数据包由当前帧号和玩家输入数据组成
var frameDataPackage = {};
frameDataPackage.seqNum = currentSeqNum;
frameDataPackage.payload = userInputDataBuffer;
// 调用网络数据发送接口
send(frameDataPackage);
// 清除玩家输入缓存
userInputDataBuffer.clear();
}
我们只要在客户端的帧循环里调用这个函数就实现了客户端帧数据的发送逻辑。
客户端如何接收帧数据
客户端接收来自服务器同步过来的帧数据的处理逻辑要比发送帧数据的逻辑来得复杂一点。无论是TCP还是UDP,从网络过来的数据难免会有波动,这个时候我们需要考虑这样的异常情况,比如一个帧数据包过来后,发现客户端的当前帧号和数据包的帧号不一样该怎么办。客户端接收帧数据的逻辑可以用以下伪代码简单表示出来:
ReceiveFrameData(frameData) {
// 客户端当前帧和数据包的帧号一样,则执行这个数据包
var currentServerSeqNum = frameData.seqNum;
if (currentServerSeqNum == currentSeqNum) {
handleFrameData(frameData);
} else {
// 将数据包保存到帧数据缓存字典中
frameDataDictionary[frameData.seqNum] = frameData;
// 我们可以认为当前客户端对帧数据的执行落后了,
// 所以我们要加速执行赶上当前的服务器
while (currentSeqNum < currentServerSeqNum){
handleFrameData(frameDataDictionary[currentSeqNum++]);
}
}
}
服务器端如何接收和发送帧数据
服务器接客户端的帧数据包后只需缓存起来即可,暂且省略。而服务器端发送帧数据的逻辑是实现lockstep的关键。既然是lockstep,那么服务器得确保收到了各个客户端发过来的帧数据才能做同步。服务器发送帧数据的逻辑可以用伪代码简单表示如下:
SendFrameDataServer(){
// 确认服务器收到了各个客户端的帧数据
if(userFrameDataBuffer.length() == userCount){
// 组装帧数据包
var serverFrameDataPackage = {};
serverFrameDataPackage.seqNum = currentServerSeqNum;
serverFrameDataPackage.payload = userFrameDataBuffer;
// 调用网络发送接口
send(serverFrameDataPackage);
currentServerSeqNum++;
// 清空帧数据缓存
userFrameDataBuffer.clear();
}
}
更多
至此,我们分别从客户端和服务器端来简单介绍了一个简单的帧同步方案。之所以说简单是因为有很多异常的情况我们还没有处理,比如:
- 客户端漏了某个服务器的帧数据包该怎么办?
- 服务器端在发送帧数据的逻辑里,要是出现一直收不到某个客户端的帧数据包该怎么办?
第一种情况,我们可以涉及一套请求服务器补包的逻辑。第二种情况则可以设计一个超时,即在具体时间内收不到某个客户端的包,服务器还是会继续发送帧数据包保证lockstep能继续下去。这些异常的情况要处理起来还是相当的繁琐,这里先暂时略过,有时间再补上。