1.为了方便处理,所有的逻辑帧都是等服务器返回后执行,暂时不做预测等处理。
客户端的每次操作不会立即生效,而是上传给服务器,客户端执行操作的时间都收到服务器发送的为准,服务器保证的是所有客户端都以同一帧执行该操作。
2.逻辑帧和渲染帧分离,像王者荣耀逻辑帧15帧,渲染帧30帧或者60帧
帧锁定同步(Lock step sync)
1.客户端定时(比如50毫秒)上传操作控制信息,大家都流畅的话,服务器也是同频广播
2.一旦有其他客户端卡顿,则会导致,服务器没法在一个点采集到全部的客户端信息,导致一直等待,这间接导致了客户端收不到服务器的下一个关键帧而等待,直到收到消息为止
乐观帧同步(frame sync)参考韦一笑写的
针对传统严格帧锁定算法中网速慢会卡到网速快的问题,实践中线上动作游戏通常用“定时不等待”的乐观方式再每次Interval时钟发生时固定将操作广播给所有用户,不依赖具体每个玩家是否有操作更新:
1. 单个用户当前键盘上下左右攻击跳跃是否按下用一个32位整数描述,服务端描述一局游戏中最多8玩家的键盘操作为:int player_keyboards[8];
2. 服务端每秒钟20-50次向所有客户端发送更新消息(包含所有客户端的操作和递增的帧号):
update=(FrameID,player_keyboards)
3. 客户端就像播放游戏录像一样不停的播放这些包含每帧所有玩家操作的 update消息。
4. 客户端如果没有update数据了,就必须等待(),直到有新的数据到来。
5. 客户端如果一下子收到很多连续的update,则快进播放。
6. 客户端只有按键按下或者放开,就会发送消息给服务端(而不是到每帧开始才采集键盘),消息只包含一个整数。服务端收到以后,改写player_keyboards
虽然网速慢的玩家网络一卡,可能就被网速快的玩家给秒了(其他游戏也差不多)。但是网速慢的玩家不会卡到快的玩家,只会感觉自己操作延迟而已。另一个侧面来说,土豪的网宿一般比较快,我们要照顾。
随机数需要服务端提前将种子发给各个客户端,各个客户端算逻辑时用该种子生成随机数,另外该例子以键盘操作为例,实际可以以更高级的操作为例,比如“正走向A点”,“正在攻击”等。该方法目前也成功的被应用到了若干实时动作游戏中。
指令缓存
针对高级别的抽象指令(非前后可以覆盖的键盘操作),比如即时战略游戏中,各种高级操作指令,在“乐观帧锁定”中,客户端任何操作都是可靠消息发送到服务端,服务端缓存在对应玩家的指令队列里面,然后定时向所有人广播所有队列里面的历史操作,广播完成后清空队列,等待新的指令上传。客户端收到后按顺序执行这些指令,为了保证公平性,客户端可以先执轮询行每个用户的第一条指令,执行完以后弹出队列,再进入下一轮,直到没有任何指令服务器下发之前,可以对指令队列做排序,按序执行。这样在即时战略游戏中,选择 250ms一个同步帧,每秒四次,已经足够了。如果做的好还可以象 AOE一样根据网速调整,比如网速快的时候,进化为每秒10帧,网速慢时退化成每秒4帧,2帧之类的。
空帧也要广播
房间帧同步相关协议
//cmd=11126|广播帧 message BroadcastFrame_S2C_Msg{ required int64 frame=1; repeated RoleCommandDTO roleCommandDTO=2; } //cmd=200001|申请匹配 message ApplyMatch_C2S_Msg{ } //cmd=200001|申请匹配 message ApplyMatch_S2C_Msg{ required Code code = 1; } //cmd=200002|广播匹配结果 message BroadcastMatchResult_S2C_Msg{ required int64 roomId=1; } //cmd=200003|进入房间 message EnterRoom_C2S_Msg{ required int64 roomId=1; } //cmd=200003|进入房间 message EnterRoom_S2C_Msg{ required RoomDTO roomDTO=1; } //cmd=200004|离开房间 message LeaveRoom_C2S_Msg{ required int64 roomId=1; } //cmd=200004|离开房间 message LeaveRoom_S2C_Msg{ required int64 roleId=1; } //cmd=200005|递交指令 message CommitCommand_C2S_Msg{ required CommandDTO commandDTO=1; } //cmd=200006|断线重连 message Reconnect_C2S_Msg{ required int64 frameId=1;//帧数 } //cmd=200006|断线重连 message Reconnect_S2C_Msg{ required int64 startFrameId=1;//开始帧数 required int64 endFrameId=2;//结束帧数 repeated FrameDTO frameDTO=3; }
匹配房间信息
message RoomDTO{ required int64 id=1; required string name=2; optional bytes info=3;//地图信息 repeated NPlayerDTO playerDTO=4; optional int64 frameId=5;//当前帧 repeated FrameDTO frameDTO=6;//历史帧集合 }
指令信息
message CommandDTO{ required int32 type=1; optional float x=2;// optional float y=3;// optional float radius=4;//轮盘半径 optional string skillId=5;//技能id } message RoleCommandDTO{ required int64 roleId=1; repeated CommandDTO commandDTO=2; } message FrameDTO{ required int64 frameId=1; repeated RoleCommandDTO roleCommandDTO=2; }
上面的也可以把所有指令集放到一个队列里,
如何做到按照不同的间隔播放?
————–
PS:可以把整段战斗过程的操作和随机数种子记录下来,不但可以当录像播放,还可以交给另外一台服务端延迟验算,还可以交给其他空闲的客户端验算,将验算结果的 hash值进行比较,如果相同则认可,如果不通则记录或者处理,服务端如果根据游戏当前进程加入一些临时事件(比如天上掉下一个宝箱),可以在广播的时候附带。
断线重连的话,需要在断线前到连线上来之间漏下的帧都加速播放。
所有的运算都需要基于整数或者叫定点数,不能是浮点数(只针对逻辑帧来说,渲染帧随便)
很多人初次接触帧同步里面的问题,就是在写逻辑的时候和本地进行了关联、和“我”相关,这样就导致不同客户端走到了不同的分支。实际上,真正客户端跟逻辑的话,要跟我这样一个概念无关。
那么为什么不卡顿了,画面不抖动了?
最后一个关键点,是本地插值平滑加逻辑与表现分离。客户端只负责一些模型、动画、它的位置,它会根据绑定的逻辑对象状态、速度、方向来进行一个插值,这样可以做到我们的逻辑帧率和渲染帧率不一样,但是做了插值平滑和逻辑表现分离,画面不抖了,延迟感也是很好的。这里用到了填充帧
填充帧:由于设备性能和网络延迟等原因,服务器广播频率不可能达到客户端的更新频率。若只使用关键帧来驱动游戏运作,就会造成游戏卡顿,影响体验。因此,除关键帧外,客户端需要自行添加若干空数据帧,以使游戏表现更为流畅
如何做到逻辑帧和渲染帧分离?
unity的Update是渲染帧的每一帧,请勿将逻辑直接写在Update里,一次Upadte可能会跑好几次逻辑帧,也可能一次都不跑
玩家进行的摇杆控制,操作太过于频繁,如果每次都直接向服务器发送操作,会导致传输流量过大。客户端每间隔50ms,发送一次。而且,还有一个优化,如果客户端按住一个方向不动,不松手,而且不改变方向,就不会发送操作,只有在方向改变的情况下才发送。
如何做到操作和播放的流畅性?
一般来说,我们都希望游戏中的角色控制是灵敏的,实时的。我们的游戏角色往往在会玩家输入操作后的几十分之一秒内,就开始显示变化。在帧同步游戏中,我们可以让玩家一输入完操作,就立刻发包,然后尽快在下一个收到的网络帧中收到这个操作,从而尽快的完成显示。然而,网络并不是那么稳定,我们常常会发现一会快一会慢,这样玩家的操作体验就非常奇怪,无法预测输入动作后,角色会在什么时候起反应。这对于一些讲求操作实时性的游戏是很麻烦的。比如球类游戏,控制的角色跑的一会儿快一会儿慢,很难玩好“微操”。要解决这个问题,我们一般可以学习传输语音业务的做法,就是接收网络数据时,不立刻处理,而是给所有的操作增加一个固定的延迟,后在延迟的时间内,搜集多几个网络包,然后按固定的时间去播放(运算)。这样相当于做了一个网络帧的缓冲区,用来平滑那些一会儿快一会儿慢的数据包,改成匀速的运算。这种做法会让玩家感觉到一个固定延迟:输入操作后,最少要隔一段时间,才会起反应。但是起码这个延迟是固定的,可预计的,这对于游戏操作就便捷很多了,只要掌握了提前量,这个操作的感觉就好像角色有一定的“惯性”一样:按下跑并不立刻跑,松开跑不会立刻停,但这个惯性的时间是固定的。
如何保证客户端独立计算的正确,即一致性
帧同步的基础,是各个客户端基于相同的操作指令,各自执行逻辑,能得到一样的结果。影响结果的因素和对应方案如下:
因素 | 解决方案 |
随机数值 | 随机种子 |
浮点数计算偏差 | 用定点数,TrueSync |
执行的顺序 | 有一个统一的逻辑tick入口,来更新整个战斗逻辑,而不是每个逻辑自己去update,保证每次tick都从上到下,每次执行顺序一致 |
Coroutine内写逻辑带来的不确定性 | 逻辑部分不用 |
Dictionary带来的不确定性 | 逻辑部分不用 |
客户端卡顿 | 用FixedUpdate去执行,像王者荣耀逻辑帧15帧 |
参考: