关于网络同步的经验与思考
前言
截至目前,笔者也接触了一些游戏项目的开发,接触过一些网络同步方案,在这里做个总结以及提出一些自己的思考。笔者目前接触的网络同步方案大致划分为三种:快照同步、状态同步和帧同步。具体采用什么方案,需要根据游戏需求以及服务器的架构需求来指定,而不是盲目的选择。
快照同步
这是笔者接触比较多的同步方法,也是三种同步方案中最为简单的。快照同步常用于逻辑简单的游戏,多为房间类型的休闲游戏,卡牌游戏等,游戏状态比较简单,状态修改也多位于服务端。由于游戏的状态比较少,将游戏状态整理成一张张快照,主动或被动的更新给客户端。
就以房间类休闲类游戏来看,游戏的状态大致可以划分成房间状态、玩家状态集合、游戏进程状态这三张快照;
房间快照多储存一些全局性质的信息,比如游戏的基本信息(回合数、玩家数量、定时器id之类的)和游戏相关元数据(游戏规则、游戏配置等);
玩家状态快照则记录玩家的状态,包括玩家的基本状态(比如通用的玩家基本信息,如血量、蓝量、准备状态之类的)、游戏状态(涉及游戏序列的数据状态);
游戏进程状态快照则记录整个游戏进程的信息,描述当时游戏进程的信息集合,是和游戏需求最为相关的快照。通过客户端传送请求触发游戏进程状态的改变,并且同步到所有的玩家。
对于快照的同步,存在服务端主动同步和被动同步两种触发方式。主动模式多为新的玩家实体加入游戏时,由客户端触发,获取能够渲染整个游戏的快照信息;被动模式则是在快照在被客户端请求更新时,将快照主动推送给客户端,客户端接收后立刻更新游戏页面的渲染。
在游戏逻辑不复杂的情况下,快照同步是比较合适的方案,可以在较小的开发压力下实现基本的网络同步需求。但是如果快照信息过于庞大,或者游戏逻辑复杂,快照同步可能会导致性能问题,此时需要考虑其他同步方案。
帧同步方案
帧同步方案我写过一篇详细的文章去介绍,帧同步方案详解。这里提出一些我的理解和思考。
首先是服务端在帧同步中的角色,担任的职责是什么?大致的作用分为两个:转发和存储;服务端需要在帧内接收到的客户端指令转发给所有的客户端,并且将每一帧的游戏状态进行存储,以便于后续的回放和调试。
站在服务端的视角,服务端就是在不断的接收客户端的指令,并按照固定帧率使用这些指令生成帧结构,并将生成的帧信息广播给所有的客户端。
下面介绍基本帧同步实现方案。首先给出一个基本的帧同步基本结构:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 客户端发送的指令,支持序列化和反序列化
<template NetSerialize>
struct Command {
// init
};
class LockStep {
public:
bool running_;
size_t frame_rate_;
size_t frame_limit_;
std::function<vector<char> (size_t, std::vector<Command>)> frame_serializer; // 这里是帧序列化函数
std::unordered_map<size_t, TcpConnection> clients;
std::unordered_map<size_t, size_t> client_frame; // 客户端当前帧
size_t current_frame_;
std::vector<Command> current_commands_; // 当前帧指令集合
std::unordered_map<size_t, std::vector<char>> frame_cache_;
void tick(); // 每帧更新
};
对于连接的管理,帧数据的序列化这些和帧同步机制关系不大的模块先不做说明,我们将重点放在帧如何处理客户端发送的指令,如何广播,如何对短线重连的客户端进行恢复上。
接收到客户端的指令,需要将其放在当前帧的指令集合中。
1
2
3
4
void LockStep::add_command(Command command) {
// 将指令放入当前帧的指令集合中
current_commands_.push_back(command);
}
重点来了,当我们的帧定时器触发时,我们要生成本帧的数据,并广播给客户端,并储存刷新结构;对于存在落后当前帧的客户端,我们需要进行补帧处理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void LockStep::tick() {
if (frame_limit_ && current_frame_ >= frame_limit_) {
// 超过帧限制
stop();
return;
}
current_frame_++;
size_t current_frame = current_frame_;
vector<Command> commands = current_commands_;
current_commands_.clear();
for(auto& [client_id, connection] : clients) {
auto client_cur_frame = client_frame[client_id];
for(; client_cur_frame < current_frame; client_cur_frame++) {
if (!frame_cache_.count(client_cur_frame)) {
frame_cache_[client_cur_frame] = frame_serializer(client_cur_frame, commands);
}
connection.write(frame_cache_[client_cur_frame]);
}
client_frame[client_id] = current_frame;
}
}
注意定时器的触发时间间隔是我们设定的帧率。上面就是一个基本的帧同步实现方案。但是这个方案存在一个问题,就是游戏时间如果很长的话,会缓存大量的帧数据,客户端在短线重连时,会导致网络IO的压力增大,需要优化。优化的思路是这样的,使用缓存状态来代替过时帧。
我们需要在服务端维护一个当前的游戏状态快照,当客户端请求补帧时,直接将这个快照发送给客户端,而不是逐帧发送历史帧数据。这样可以大大减少网络IO的压力,提高补帧的效率。
如何获得这个游戏快照呢?这个快照通常需要客户端提供,客户端每隔一定的帧数就发送一次快照过来,服务端接收到快照后,将快照对应的帧之前的所有帧进行清除,并将快照保存。后续请求补帧就是快照+帧缓存。
状态同步
本人状态同步的经验来自于一个MMO服务器开发的经验、所谓状态同步,其实就是服务端也模拟一个游戏运行环境,同样持有一个帧处理环境。和客户端不同的是,客户端的输入信息来源于外部硬件的输入,而服务端的输入来自于客户端,客户端将外部硬件的输入抽象成事件发送给服务端。服务端接收到这个事件后,运行游戏逻辑。
客户端只需负责即时的移动和动作,对于属性状态等交予服务端进行同步。服务端需要在状态发生变化时进行同步动作。
状态同步分为两种模式:全量状态同步和增量状态同步;全量状态同步通常发生在客户端进入场景时,需要得到当前场景下的所有状态,来渲染场景画面;增量状态同步发生在场景内,每帧发生修改的状态,同步给已经存在与场景内的客户端。
和客户端一样,服务端也需要update函数来处理每帧的更新和同步。服务端运行游戏逻辑,为了方便客户端同步状态,服务端可以采用和客户端相同的架构。现在客户端主流的架构是ECS架构,关于ECS架构的介绍可以参阅我之前的文章。状态同步的状态在实体 / Entity和组件 / Component中,同步发生的时机在于每一帧的update函数中。
使用AOI优化状态同步的效率,使得服务端能够承载更多的客户端连接。状态同步的关键在于如何决定每个状态的序列化和反序列化的过程。一个方便的做法是将状态用protobuf规定一个一模一样的,将序列化和反序列化的工作外包给protobuf;还有一种办法是采用7-bits编码方式,客户端和服务端协商好如何编码结构体。这里提供一个序列化思路:
对于全量状态同步,直接使用protobuf进行序列化和反序列化;
对于增量状态同步,由于需要支持增量识别能力,这里需要自定义序列化方法。方法是这样的,设置一个脏掩码,记录每个状态的修改情况,在序列化时只序列化脏数据,在反序列化时根据脏掩码恢复状态。
下面完整阐述下,ECS架构下状态同步是如何执行的。首先要明确一点,所有的实体和组件都具备全量序列化和增量序列化的能力。
首先会有个外部驱动的定时器,负责回调update函数。update函数会遍历场景内的所有实体,针对每个实体,会准备两份数据,一份是同步给自己的,一份是同步给其他玩家。因为序列化会因为是否同步给自己发生变化(比如一些个人隐私的数据不能被同步到其他玩家的视野中)。数据的准备过程其实对实体和组件增量式序列化一遍,写入缓存中。准备好数据后,同步给自己的数据直接发送给自己,同步给其他玩家的数据要放入集合中由AOI系统负责指导如何发送。
AOI系统会根据自身系统中维护的所有玩家的AOI状态中的关注对象,将关注对象的数据发送给该玩家。关于AOI如何实现,可以参考我之前的文章。
到这里一个高性能的MMO状态同步框架就基本实现了。
结语
我常常在思考是否具备更加高效、更加灵活的网络同步方案,也会时刻关注业界的最新动态和研究进展。