帧同步流程与概述

逻辑与单机没有区别,只是输入分别来自服务器本地

帧同步如何同步?

  • 帧同步与单机类似,服务器转发所有玩家的输入,客户端根据接收到的输入推进游戏过程
  • 同样的代码,同样的输入 –> 同样的结果
  • 我们的服务器,每隔一端时间,将采集的玩家的操作,发给所有的客户端,继续采集下一次的操作,等一下一次时间到,又把采集到的操作发送给所有客户端;
    • 服务器|——–|——–|——–|——–|
    • 客户端:收到服务器的操作—》计算游戏逻辑—》上报下一帧的操作给服务器;
    • 由于只有一段时间来采集数据,所以同步数据/人数不能太大
    • 所以如果人数较多,可以使用状态同步

帧同步的优点

  • 实时性好

帧同步缺点

  • 计算在客户端,容易作弊

帧同步,每隔多长时间同步一次呢?

  • 上限: 网络传输时间,1000/20 = 50 帧
  • 下限:用户体验,人体反应时间:50100ms,也就是2010帧,我们取15帧,1000/15 = 66.66ms
    • 比如人体一秒钟可以摁下多少次鼠标

带宽是否能受得了:

  • 5000人–>500房间;每隔房间都会进行帧同步(10个,10个,10个)
  • 1秒—>平均每帧,每人16个字节,摇杆—>角度(0, 360,2个字节),1个字节(256种不同技能)
  • 16 字节 * 10人 * 15帧 * 500 房间 –>1.2MByte–> 1.2 * 8 = 9.6Mbit

UDP还是TCP

  • 通常情况下TCP也可以做到帧同步,但是当网络发生波动时,如果1发送延迟了,B没有受到1的情况下,会等到B接收到才1才发送2。所以当网络波动时,B接收帧数据会落后。
  • 但是UDP没有顺序和重传
  • 但是UDP有丢包问题
    |—–| ————->|——|
    | A | ————->| B |
    |—–| ————->|——|

流程

服务器
  • 服务器: —>比赛对象—>房间内;
  • (1)服务器上每个比赛对象,都又一个成员frameid(帧ID),保存了当前比赛下一帧要进入的id;
  • (2)我们在服务器上定义一个数据结构match_frames(每帧的操作),用来保存我们所有的玩家的每帧的操作;
    • 作用:录像回放,断线重连,检查作弊,UDP丢包时补发给客户端。
      • (3) next_frame_opt:每帧服务器采集到的客户端的操作:
1
2
3
4
5
6
7
8
9
next_frame_opt = {
frame_id = self.frame_id;
opt= {
玩家操作,
玩家操作,
玩家操作,
。。。
}
}
  • 启动定时器,每隔66m将搜集的帧操作发送给客户端
    • (5)保存我们当前的操作,到match_frames
    • (6)遍历每个玩家,向每个玩家发送我们的帧操作;
  • (7)服务器进入下一帧: self.frameid = self.frameid + 1
  • (8)服务器进入采集下一帧模式,清空上一帧的操作;
  • (9)发送服务器认为这个玩家还没有同步的帧
    • 每个玩家对象sync_frameid。记录当前这个客户端,已经同步到了多少帧。
    • 从sync_frameid + 1 一直同步到最新的帧;
    • 有时候100帧包含[99帧,100帧],为了应对UDP丢包和时序问题,补发的帧;
  • (10)采用UDP –> 将我们100帧的数据包发送出去100帧:[99帧,100帧]
客户端
  • (11)通过网络收到帧同步的数据包以后,on_logic_update 更新逻辑。
  • (12)每个客户端有一个sync_frameid,记录当前客户端真正同步到哪个帧;
  • (13)如果收到的帧ID,小于客户端已经同步过的帧id,直接丢弃这个帧;
    • a:为什么会出现这样的情况? 受到99帧,却处理完了100帧;因为 UDP先发后到,后发先到;
    • b:为什么我们没有收到99帧,就开始处理100帧,还能同步呢?
    • 因为,服务器发送100帧的时候看到客户端sync_frameid小于99帧,于是服务器发了【99帧、100帧】的集合
  • (14):如果上一帧的操作不为null,那么这个时候,我们处理下一帧之前一定要先同步上一帧的结果;
    客户端A: |—–|——|—-6.3—l
    客户端B: |—–|——|—-6.1—l
    在播放动画的帧与帧之间,我们会出现时间的差异,会导致位置等不同步;
    logic_pos: 统一使用66ms —>迭代计算出新的位置和结果;
    帧同步:每帧都同步,处理下一帧之前,每帧都要同步—>同样的输入—>同样的输出;
  • (15)跳帧: 快速的同步完过时的帧,直到最新的帧,如在98,帧受到 [99,100]帧的操作,快速同步到100帧。
  • (16)控制我们的客户端,来根据操作,来播放动画,更新我们的逻辑推进;
服务器
  • (17) capture_player_opts:采集自己的操作,将你认为的””下一帧”上报给服务器,next_frame.frameid = this.sync_frameid + 1;
  • (18)收到玩家的操作,更新服务器上认为玩家已经处理的帧id;
  • (19)如果收到玩家操作的帧ID,next_frame_opts.frameid 不等于马上要触发的帧ID;收到玩家过时的操作;
    99,100 —>丢弃;这样丢弃,会影响玩家的手感吗?
    肯定不好,但是不影响玩家; 15FPs,按一个按钮,4次,
  • (20)保存玩家的操作,等待一下帧的触发——>Goto到逻辑(4)

预测回滚

  • 在之前流程中没有提到预测但是有追帧的概念,也就是说如果客户端发送帧数据时产生了网络波动,服务器未收到该客户端当前帧的数据,那么该客户端操作的游戏对象只能原地罚站
  • 状态同步中因为只同步玩家状态,所以在15帧~30帧的更新速度是非常卡顿的,如果同步的是玩家的状态如:
    1
    2
    3
    4
    5
    player_state = {
    position,
    rotation,
    ...
    }
  • 那么客户端的表现将非常糟糕,所以状态同步一定会进行一定程度上的预测
那么帧同步如何进行预测呢?
  • 客户端不仅仅向服务器发送自己的操作并接收服务器的操作ServerBuffer,同时还本地记录有自己的操作InputBuffer
  • 在客户端进行逻辑更新时,如果服务器的输入到达那么使用ServerBuffer驱动数据,但是如果服务器的输入没有到达那么使用InputBuffer,并且对其他玩家的输入进行预测
  • ServerBufferInpuBuffer不仅仅有自己的输入还有其他玩家的输入。
  • 所以这里涉及到一个问题预测失败