Reference六:帧同步联机战斗(预测,快照,回滚) - 知乎 (zhihu.com)
关于帧同步的想法(预测和回退) - 知乎 (zhihu.com)
Deterministic Lockstep | Gaffer On Games — 确定性锁步 |灯光师谈游戏
帧同步优化难点及解决方案 - UWA问答 | 博客 | 游戏及VR应用性能优化记录分享 | 侑虎科技 (uwa4d.com) 这一篇是写的最好的
云风的 BLOG: lockstep 网络游戏同步方案 (codingnow.com)
转载请标明出处
前言概览现有的帧同步视频资料与文档资料,要不就是仅仅简述一个帧同步的架构,再就是以及写好了一个比较具有规模的帧同步框架,什么定点数,UDP,自定义物理同步一应俱全,一时间让读者摸不着头脑。
本教程从一个小DEMO出发,从最古早的帧锁定同步算法开始逐步讲解追帧、录像回放、预测回滚
本DEMO的基础是ancientElement/NetGameBook (github.com)TCP通信框架。
有的同学就会说:为什么用TCP呢,为什么不用UDP?
其实,在初期学习,我都想要将学习成本尽可能的压缩,现在刚好有不需要处理乱序和丢包的TCP干嘛不去用呢?
这里引用一下SkyWind的话:
图1 TCP还是UDP
其实帧同步和状态同步,在我的理解中,帧同步是转发操作,状态同步是转发结果。
帧同步还需要经过操作输入–》模拟,的过程才可以得到结果,这就导致了一个问题,后来的客户端怎么办,他没有收到到过其他玩家之前的操作输入怎么得到其他玩家现在的状态,难不成要储存一个玩家状态发送给后加入的玩家吗?
NONONO,早期的话,可以设计成全部的玩家进入才会开始游戏,这样所有玩家的都会有相同的操作输入。并且这样的设计是无法实现掉线重连和后来加入的。
要设计成为支持掉线重连,需要保存一个所有玩家的每帧操作输入。有玩家加入之后,他的客户端本地发送一个更新第0帧操作的消息,服务器收到以后,发现卧槽我到跑到第10帧了这老弟还要更新第0帧,在统一发送第10帧时,单独针对发送给这个老弟的消息中将第0帧到第10帧的所有操作打包,其他人的照成不变。
老弟服务器接收到第0帧到第10帧的所有操作后,将其玩家的输入操作快速模拟一遍直到第10帧,真就是为什么你打王者荣耀的时候掉线了,重新连接后会有自己和其他玩家的快速影像。
帧同步和状态同步的对比状态同步的方案特点
优点
安全(因为有关键逻辑都在服务器上)
网络要求低
断线重回快(因为有状态快照)
客户端性能优化(视野之外逻辑可以不要的,反正服务器在算全局的)。所以千人大战这种一定是C/S架构的。
缺点
开发慢(因为要联调)
录像回放实现较难
打击感难调
耗流量
帧同步的方案特点
优点和缺点恰好是状态同步方案的互补。其中外挂是一个重点,而断线重回时间长是一个难点,不能胜任千人大战是无法突破的瓶颈(因为所有东西都要放在客户端算)。
此外,帧同步常用到buffer,其延迟比状态同步是要大的。
帧锁定同步算法-从Demo开始讲解可以参考一下这一张图片的流程:
图2 帧锁定同步流程图
文字流程:
服务器
玩家就位后发送第0帧的操作,第0帧都是空操作。
等待第1帧的所有玩家接收操作到位。
接收到位后,广播所有玩家当前帧操作,当前帧加一。
客户端:
等到第0帧的到来,第0帧没到不允许上传操作。
接收到第0帧后,更新逻辑帧。
接收到第0帧后,等待66ms(FPS:15)搜集并且上传第1帧的操作,没有接收到不允许上传。
没错就是这么简单,都说了是浅入浅出。
玩家脚本帧同步的性质决定了,BasePlayer脚本中只能模拟,输入只能由外部给到。在OnLogicUpdate函数中,根据参数PlayerInputData来模拟当前帧的逻辑操作。
在逻辑层面更新速度只有15FPS,距离60FPS,差了很大一段路程,如果按照我们的逻辑帧来更新显示,画面将会非常不堪🙈。
这里逻辑帧更新仅仅通过输入计算得到结果,并不做画面上的更新,如下:
12345678910protected virtual void MoveUpdate(float delta, PlayerInputData playerInput) { Move(delta, playerInput); }public void Move(float delta, PlayerInputData playerInput) { var direction = new Vector3(playerInput.JoyX, 0, playerInput.JoyY); m_targetPos += direction.normalized * m_velocity * delta; }
由于我们是操作角色运动,我们将实际的移动放在OnFixedUpdate中,如下:
1234public void OnFixedUpdate(float delta) { m_go.transform.position = Vector3.MoveTowards(m_go.transform.position, m_targetPos, delta * m_velocity); }
附:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465public class BasePlayer { public enum STATEENUM { idle, move } float m_velocity; STATEENUM m_state; GameObject m_go; Vector3 m_targetPos; public BasePlayer(STATEENUM state, GameObject gameObject, float velocity) { m_state = state; m_go = gameObject; m_velocity = velocity; m_targetPos = m_go.transform.position; } public void OnFixedUpdate(float delta) { m_go.transform.position = Vector3.MoveTowards(m_go.transform.position, m_targetPos, delta * m_velocity); } public void OnLogicUpdate(float delta, PlayerInputData playerInput) { if (playerInput.JoyX != 0 || playerInput.JoyY != 0) { m_state = STATEENUM.move; } else { m_state = STATEENUM.move; } switch (m_state) { case STATEENUM.idle: IdleUpdate(delta); break; case STATEENUM.move: MoveUpdate(delta, playerInput); break; } } protected virtual void MoveUpdate(float delta, PlayerInputData playerInput) { Move(delta, playerInput); } protected virtual void IdleUpdate(float delta) { m_targetPos = m_go.transform.position; } public void Move(float delta, PlayerInputData playerInput) { var direction = new Vector3(playerInput.JoyX, 0, playerInput.JoyY); m_targetPos += direction.normalized * m_velocity * delta; } }
PlayerMgr用来注册玩家、发送注册消息、统一更新所有玩家的逻辑帧、表现帧的管理脚本。
有点类似与ECS中的System的作用。
也类似与命令模式中的CommadMgr。
但是这里我们与命令模式不同,我们不储存任何状态,即使要存储,也会放到服务端存储。
我们需要在这里储存当前客户端的玩家ID,和其他玩家:
12public int PlayerID { get; private set; } //控制的玩家的ID private Dictionary
同时提供发送注册玩家请求、监听其他注册玩家注册等功能。
附:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980public class PlayerMgr { public int PlayerID { get; private set; } //控制的玩家的ID private Dictionary
NetTick这个脚本中实现了,上面流程中的所有客户端操作,要注意看哦(⊙o⊙)。
接收UpdateMessage,搜集玩家操作上传Upload。
我们上传操作时如果服务器没有下放第0帧,或者未接收到上一次上传的操作,都不能上传下一帧 如过我们已经接收到上一次上传的操作的操作,等待66ms再次上传,同时m_reciveFromLastUpLoad置为false:
1234567891011121314private void Upload(float delta) { //如果没有接收到当前帧则等待 if (m_playerMgr.PlayerID == -1) return; if (!m_reciveFromLastUpLoad) return; m_timer += delta; if (m_timer >= m_upLoadInterval) { m_timer = 0; UpLoad(); AEDebug.Log("发布:" + (m_curFrame + 1) + "帧数据"); m_reciveFromLastUpLoad = false; } }
在接收时判断收到的帧数据,是否是当前帧的下一帧,是则更新数据,并且同时m_reciveFromLastUpLoad置为true,表示接收到上一次上传的操作。
1234567891011121314private void ReciveUpdateMessage(BaseMessage msg) { var updateMessage = msg as UpdateMessage; var updateDate = updateMessage.data; if (updateDate.CurFrameIndex == m_curFrame + 1) { m_curFrame = updateDate.CurFrameIndex; m_reciveFromLastUpLoad = true; m_playerMgr.OnLogincUpdate(updateDate); } AEDebug.Log(updateDate.Delta); AEDebug.Log("接收到第:" + updateDate.CurFrameIndex + "帧数据"); }
附录:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135public class NetTick : MonoBehaviour { private int m_curFrame; private bool m_reciveFromLastUpLoad; private float m_upLoadInterval; //单位秒 间隔多少上传数据 private float m_timer; //计时器 PlayerMgr m_playerMgr; [SerializeField] private string m_serverIP; [SerializeField] private int m_port; [SerializeField] private int m_FPS; [ContextMenu("开启连接")] public void StartConnect() { NetAsyncMgr.ClearNetMessageListener(); m_curFrame = -1; m_timer = 0; SetFPS(m_FPS); m_playerMgr = new PlayerMgr(); NetAsyncMgr.AddNetMessageListener(MessagePool.UpdateMessage_ID, ReciveUpdateMessage); NetAsyncMgr.SetMaxMessageFire(m_FPS); NetAsyncMgr.Connect(m_serverIP, m_port); } #if Test [ContextMenu("测试发送注册自己")] public void TestSendRegisterSelfPlayer() { m_playerMgr.SendRegisterPlayer(); } [ContextMenu("测试开始同步")] public void TestSendStartRoom() { var startRoomMsg = new StartRoomMassage(); NetAsyncMgr.Send(startRoomMsg); AEDebug.Log("开始同步"); } [ContextMenu("测试as需要消耗多少时间")] public void TestAsCostTime() { var oldTime = DateTime.Now; for (int i = 0; i < 10000; i++) { BaseMessage msg = new StartRoomMassage(); var startRoomMsg = msg as StartRoomMassage; } var newTime = DateTime.Now; var interval = newTime - oldTime; AEDebug.Log(interval.TotalMilliseconds); } #endif private void Update() { NetAsyncMgr.FireMessage(); if (!NetAsyncMgr.IsConnected) return; Upload(Time.deltaTime); } private void FixedUpdate() { if (!NetAsyncMgr.IsConnected) return; m_playerMgr.OnFixedUpdate(Time.fixedDeltaTime); } ///
服务器RoomRoom代表一个房间,这里监听成员是否就绪、成员注册、接收玩家输入等消息。
这里也是实现了上面流程中提到的服务器大部分内容。
在后期实现多房间系统,仅仅只要在消息中多加一个房间号,使用RoomMgr来再次分发房间消息。
帧数据直接存在消息里面private UpdateMessage m_currentFrameplayerInputs;方便发送,但是切记要清空,/(ㄒoㄒ)/。
如何判断是否收到所有帧数据呢?private Dictionary
接收到房间开始后,下发第一帧空操作:
1234567891011121314151617181920private void StartRoom(BaseMessage message, ClientSocket socket) { CurFrame = 0; m_lastSendUpdateMsg = DateTime.Now; m_currentFrameplayerInputs.data.CurFrameIndex = CurFrame; m_currentFrameplayerInputs.data.NextFrameIndex = CurFrame + 1; foreach (var item in m_players) { var playerInput = new PlayerInputData(); playerInput.PlayerID = item.Key; playerInput.JoyX = 0; playerInput.JoyY = 0; m_currentFrameplayerInputs.data.PlayerInputs.Add(playerInput); } socket.serverSocket.Broadcast(m_currentFrameplayerInputs); m_currentFrameplayerInputs.data.PlayerInputs.Clear(); Debug.Log("接收到房间开始并发布第0帧"); }
下面是我们的重头戏,接收各个玩家的操作,如果接收的操作是服务器上下一帧的操作,添加进帧数据中,并且在 m_IDRecived这个登记表中打勾,判断是否接收到位,到位之后转发,服务器逻辑帧+1。
123456789101112131415161718192021222324252627282930313233343536373839private void RecivePlayerInput(BaseMessage message, ClientSocket socket) { lock (m_currentFrameplayerInputs) { var upLoadMessage = message as UpLoadMessage; if (upLoadMessage.data.CurFrameIndex == CurFrame + 1) { m_IDRecived[upLoadMessage.data.PlayerID] = true; m_currentFrameplayerInputs.data.PlayerInputs.Add(upLoadMessage.data); Debug.Log("接收第" + upLoadMessage.data.CurFrameIndex + "帧"); foreach (var item in m_IDRecived.Values) { if (!item) { return; } } //服务器帧更新 CurFrame += 1; var span = DateTime.Now - m_lastSendUpdateMsg; m_lastSendUpdateMsg = DateTime.Now; Debug.Log(span.TotalSeconds.ToString()); //广播 m_currentFrameplayerInputs.data.CurFrameIndex = CurFrame; m_currentFrameplayerInputs.data.NextFrameIndex = CurFrame + 1; m_currentFrameplayerInputs.data.Delta = (float)span.TotalSeconds; socket.serverSocket.Broadcast(m_currentFrameplayerInputs); Debug.Log("发布第" + upLoadMessage.data.CurFrameIndex + "帧"); //清理 m_currentFrameplayerInputs.data.PlayerInputs.Clear(); for (int i = 0; i < m_IDRecived.Count; i++) { m_IDRecived[i] = false; } } } }
多线程问题,服务器端使用线程池接收数据,会同时到达两个帧数据。
在 C# 中,当两个线程同时修改一个共享资源时,可能会导致竞态条件(Race Condition)的发生,从而引发意外的结果或者未定义的行为。
使用线程锁,防止产生竞态条件,
12345678910private object lockObject = new object();private int sharedResource;// 在修改共享资源时使用锁lock (lockObject){ // 修改共享资源的代码 sharedResource = newValue;}
附:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131public class Room { public int CurFrame { get; private set; } private Dictionary
BUGS每次间隔66ms真的是间隔66ms才收集数据吗,而不是提前收集数据等到66ms再去发送吗?我怀疑这是输入不灵敏的一个原因。
现在总算是直到为啥要用自己的物理引擎做帧同步了,因为Unity的物理引擎没办法根据我们自己的逻辑帧来更新,逻辑帧更新和物理帧更新时间点不一致,导致相同的输入,不同的输出,这是帧同步最忌讳的。
追帧其实在TCP中 不太可能出现掉包,因为TCP的丢包重发机制和有序性
如果要引入追帧这个概念,那么服务端不能再等待所有客户的操作,必须也是以一定时间接收玩家的操作,如果时间到了还未到的玩家操作服务器将会认为是空操作,直接将下一帧发出去,服务器逻辑帧+1。
这里一定时间最好是与客户端发送同步的时间,可是要是客户端传输时间皆大于服务器的等待时间怎么办?那岂不是每一帧都是空操作?
我们先暂且将服务器等待时间略大于客户端时间,但是同样的,如果在等到时间前都到齐了,发送下一帧,服务器逻辑帧+1。
服务器在接收的时候,判断接收的帧是否是当前帧,如果不是则
客户端:该我出场了。
如果客户端接收到大于当前帧的数据