帧同步赛车技术 Demo 记录

前言

一个基于 FishNet + Unity 的双人赛车【帧同步】技术 Demo。

介绍

主要工作流程介绍:

主要流程都发生在 FishNetFrameSync 类中,它是我们的 FishNet Adapter,一个 TickNetworkBehaviour。这个东西类似 Unity 的 MonoBehaviour,以固定频率工作。在它的 TimeManager_OnTick(),即 Tick 函数中,分别调用作为 Server 和作为 Client 的 Tick 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
protected override void TimeManager_OnTick()
{
// 避免服务端先广播 F0 确认帧,客户端收到后才进入 AdvanceClientFrame(),第一笔输入变成 F1/F3
if (IsClientStarted)
{
AdvanceClientFrame();
}

if (IsServerStarted)
{
AdvanceServerFrame();
}
}

客户端的 AdvanceClientFrame 主要在比赛时处理这些工作:首先调用 ReadInput 采集 QuantizedInput,然后将其绑定到对应帧上;在客户端的 client.Predict 中计算预测执行这个输入后的状态 RaceState;最后将带有帧号和操作的 InputFrame 发送给服务器。特别地,为了模拟延迟和丢包,我们引入了 inputConditioner,它会按指定频率延迟发送或直接丢掉输入,从而制造回滚。最后,再根据当前 State 更新各个物体的位置等信息。

服务端的 AdvanceServerFrame 有三个任务。第一,模拟:服务器取出客户端上报的输入,计算状态。这时如果没有获得对应的信息(发生丢包或延迟),就沿用上一帧的操作。第二,将服务器确认的 RaceFrame 广播给各个客户端。RaceFrame 是消息结构体,里面包括帧号、各个玩家的输入,以及一些权威信息,例如谁赢了、比赛是否结束。这些状态来源于服务器的模拟。类似某个玩家是否掉线的信息,则来源于服务器天然掌握的网络状态。最后,服务器会定期计算服务器端世界状态的哈希,并广播给客户端。

预测和回滚

目前客户端使用固定延迟。我们约定形如 F0 的表达代表第 0 个逻辑帧。在目前的设计中,我们假设服务器和客户端的真实时间是同步的,即客户端到达 F0 时,服务端也到达 F0。由于我们必须为网络传输留出时间,现在当用户输入操作时,这个操作会被标记为 F2 时的操作,然后发送给服务端。这样,当服务端计算到 F2 时,F2 的用户输入已经上传。随后,服务端会向客户端下发信息。我们假设这个信息在 F4 时到达,那么客户端就可以确认 F2 是否已被服务端确认。

在输入从 F2 发送、到 F4 确认的过程中,客户端并不会干等,它会直接乐观假设 F2 已被接受,在 F2 时就开始渲染 F2 的内容。这样一来,用户输入不会始终经受延迟(大约一个 RTT),而是实时的。

但是代价是会回滚。如果在 F4 时,Server 表示 F2 的输入没有被接受,F2 的输入操作被认定为 XX,那么 Client 会根据内存中保存的 F1 状态,重置为 F1 的状态,然后重新计算 F2F3F4,最后显示修正后的 F4 状态,这可能会带来跳动。由于需要逐帧模拟,这里也有一个分层。客户端 FrameSyncClient 对上提交的接口是 Predict(QuantizedInput input),用于向 FishNetFrameSync 传入输入。内部则由 PredictFrame 逐帧模拟。

服务器计算到 F0 时,Client 未必总能及时把包发来。这时服务器会认为发生丢包,直接沿用上一帧的输入。

我们目前这个实现有几个假设可以优化。首先,我们假设时间同步,即 Client 到达 F0 时,Server 也在 F0。正是因为这个原因,玩家第一次按下按键产生的输入会被绑定到 F2,这意味着第一个操作仍会承受约 2 个 Tick(约 66 ms)的延时。后续可以让 Client 跑得更快,即 ClientF2 时,Server 仍然是 F0。这样游戏开始后,Client 直接就是 F2,第一个操作就不会有延时了。

哈希

服务端会定期计算 State 的哈希,然后发送给客户端。客户端计算自己的哈希,再检查是否一致。目前我们只是简单统计不一致次数。

后续可以考虑让客户端上报;当长期不一致时,可以对齐状态或者拒绝客户端输入。

状态

游戏有一些状态,主要分为等待、游戏中、已决定胜者、游戏被放弃等,这些被记录在 RacePhase 中。

此外,我们还有一些玩家状态 PlayerStatus,例如用户正在等待分配、等待游戏开始、游戏中、结束、断连等。服务器会在帧包中附上一些权威信息,例如某个用户断连,或者游戏已经确定胜者等。在这样的状态下,会有一些对应的处理。例如,一个玩家只能在“游戏中”状态时提交操作。

模拟

模拟规则在一个唯一确定的静态类 RaceFrameRules 中。它只对外提供一个接口 AdvanceOneFrame,计算在某个 State 下执行某个操作后得到的新 State

注意,RaceFrameRules 不是 MonoBehaviour,这意味着放一个带有 Unity 碰撞体的物体根本不能影响状态,因为状态会直接被修改成 Rules 返回的值。