Unity帧同步的实现方法
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Unity帧同步的实现方法相关的知识,希望对你有一定的参考价值。
参考技术A 状态同步简单来说就是有一个权威服务器运行着一个没有图形界面的客户端,然后服务器收集所有人的操作数据 计算后再把所有人的关键数据广播给所有人,玩家的客户端只是服务器的一个展示。缺点是有些情况下同步数据量会很大 因为计算全在服务器所以可能压力会比较大。
帧同步中的每一个客户端都是要计算所有数据,服务器只需要转发彼此的操作即可。
帧同步的同步过程:
1.收集所有人的输入,广播给所有人。
2.客户端接收到所有人的输入,客户端本地开始根据输入计算得到游戏结果。
优点 服务器压力小,同步的数据量也很小相对应的可以有更低的延迟和带宽占用,可以直接没有服务器。天生支持录像。
缺点 反作弊难度较大,所有数据都在客户端本地。
如果要做帧同步 就必须保证所有客户端再执行相同操作的情况下的结果必须百分百一样,这样我们就不可以使用Unity自带的物理引擎。(亲测 会不同步)
因为不同cpu和操作系统可能float的精度也不同 所以也要避免使用float。(虽然说float有固定标准的,按理来说现在不同平台已经都一样了,但是谨慎起见我还是没用float)
帧同步的核心逻辑也不可以写在Unity脚本的生命周期里 例如Update
首先要区分 逻辑帧和渲染帧。
我们同步的是逻辑帧,所有位移和伤害判定什么的也都是在逻辑帧中,渲染帧中做平滑处理。
Unity中的帧就可以当作是渲染帧。 每次Update就是一个渲染帧,每次FixedUpdate就可以当作是逻辑帧。 渲染帧是没有固定间隔的 性能高刷的就快间隔就短。而逻辑帧是必须固定间隔的。
我两台手机要创建房间进行游戏。
服务器接收Input数据
服务器广播Input消息
上面的例子中 服务器的那台机器 如果渲染帧卡了一秒 而逻辑帧1秒20帧 ,那么就直接一个渲染帧中调用20次逻辑帧 追赶上进度。 如果服务器卡住了 所有人也都会同步暂停。 如果你有需要 也可以做主机迁移 随时接替原来的主机 防止房主掉线 大家一起掉线。
无论帧同步还是状态同步理论上来说都要做预测回滚,状态同步做这个还好做一点。
预测回滚基本就是按照上一次的操作客户端自动多模拟一帧或者几帧 来抵消网络延迟的感觉,但是如果预测结果和之后真实发生的结果不符的时候 就需要回滚客户端到正常的结果上 然后再次预测。 (如果一个人在游戏里 反复左右移动 比如CS里对枪时 疯狂ADADADADAD左右移动 这种情况下 如果有客户端有预测回滚 就会疯狂的预测错误而回滚 客户端也会增加额外的计算压力)
预测回滚因为我自己做的效果很不好 大概如下图一样,所以我就不讲具体怎么做了。
(英雄联盟的预测回滚做的也不咋样,设置里面有个预测选项默认是关闭的 手动勾选打开之后 就算网络正常效果也跟下图一样。)
预测回滚的方案很多 并不是只有一种,像《战地3》的预测回滚就特别魔性 你如果击中敌人了 但是因为延迟导致服务器判定没击中,但是你客户端已经提示击中了 再对其他人影响不大的情况下 它会让其他所有玩家和服务器陪着他一块回滚 让子弹打中那个人。。。。 (难道这就是我快速躲进掩体里之后还被打死的原因?)。
玩家每一逻辑帧执行结束之后 把所有角色的关键数据加密为MD5 上传服务器,服务器进行比对。如果是所有人上传上来的MD5都一样 说明所有人的游戏结果都是同步的,如果有人修改了血量那么就会造成游戏不同步 自己的MD5和其他人的不一样,服务器可以强行纠正它 或者 踢掉它。
游戏结束之后上传整场比赛左右的操作数据,服务器开一个客户端 一瞬间跑完所有操作所有帧 看最终的结果与客户端上报上来结果是否一致,如果不一致肯定有人作弊了。
像透视 自瞄这种 本质上没有改变游戏数据的作弊 就不太容易检测。
Unity3D RTS游戏中帧同步实现
帧同步技术是早期RTS游戏常用的一种同步技术,本篇文章要给大家介绍的是RTX游戏中帧同步实现,帧同步是一种前后端数据同步的方式,一般应用于对实时性要求很高的网络游戏,想要了解更多帧同步的知识,继续往下看。
一.背景
帧同步技术是早期RTS游戏常用的一种同步技术。与状态同步不同的是,帧同步只同步操作,其大部分游戏逻辑都在客户端上实现,服务器主要负责广播和验证操作,有着逻辑直观易实现、数据量少、可重播等优点。
部分PC游戏如帝国时代、魔兽争霸3、星际争霸等,Host(服务器或某客户端)只当接收到所有客户端在某帧输入数据后,才会继续执行,等待直至超时认为该客户端掉线。很明显,当部分客户端因网络或设备问题无法及时上传操作数据,会影响其它客户端的表现,造成不好的游戏体验。考虑到游戏公平竞争性,这种需要等待的机制是必需的,但并不符合手游网络环境的需求。为此,需要使用“乐观”模式,即是Host采集客户端上传操作并按固定频率广播已接收到的操作数据,不在乎部分客户端的操作数据是否上传成功,且不会影响到其它客户端的游戏表现,如图1所示。
(图1)
二.剖析Unity3D
帧同步技术最基础的核心概念就是相同输入,经过相同计算过程,得出相同计算结果。按照该概念,下面将简单描述Unity3D实现帧同步时所需要改造的一些方面,Unity3D中脚本生命周期流程图如图2所示。
(图2)
帧同步需要避免使用本地计时器相关数值。因此,使用Unity3D实现帧同步的过程所需注意的几点:
1. 禁用Time类相关属性及函数,如Time.deltaTime等。而使用帧时间(第N帧 X 固定频率)
2. 禁用Invoke()等函数
3. 避免在Awake()、Start()、Update()、LateUpdate()、OnDestroy()等函数中实现影响游戏逻辑判断的代码
4. 避免使用Unity3D自带物理引擎
5. 避免使用协程Coroutine
三.具体实现
对于本文的实现,有如下定义:
关键帧:服务器按固定频率广播的操作数据帧,使用唯一ID标识,主要包括客户端输入数据或服务器发送的关键信息(例如游戏开始或结束等消息)
填充帧:由于设备性能和网络延迟等原因,服务器广播频率不可能达到客户端的更新频率。若只使用关键帧来驱动游戏运作,就会造成游戏卡顿,影响体验。因此,除关键帧外,客户端需要自行添加若干空数据帧,以使游戏表现更为流畅
逻辑帧更新时间:客户端执行一帧所需时间,可根据设备性能和网络环境等因素动态变化
服务器帧更新时间:服务器广播帧数据的固定频率,一般用于帧间隔时间差的逻辑计算
3.1 主循环
帧同步要求相同的计算过程,这就涉及到两个方面,其一是顺序一致,Unity3D主循环不可控,需自定义游戏循环,统一管理游戏对象以及脚本的执行,确保所有对象更新与逻辑执行顺序完全一致。另一方面是结果一致,凡有浮点数参与的逻辑计算需要特殊处理。
class MainLoopManager : MonoBehaviour { bool m_start; int m_logicFrameDelta;//逻辑帧更新时间 int m_logicFrameAdd;//累积时间 void Loop() { ......//遍历所有脚本 } void Update() { if (!m_start) return; if (m_logicFrameAdd < m_logicFrameDelta) { m_logicFrameAdd += (int)(Time.deltaTime * 1000); } else { int frameNum = 0; while(CanUpdateNextFrame() || IsFillFrame()) { Loop();//主循环 frameNum++; if (frameNum > 10) { //最多连续播放10帧 break; } } m_logicFrameAdd = 0; } } bool CanUpdateNextFrame();//是否可以更新至下一关键帧 bool IsFillFrame();//当前逻辑帧是否为填充帧 }
3.2 自定义MonoBehaviour
Unity3D脚本生命周期中部分函数、Invoke、Coroutine调用时机与本地更新相关,并不满足帧同步机制的要求。我们通过继承MonoBehaviour类来实现上述函数和功能需求,并使所有涉及逻辑计算的组件都继承该自定义类。
class CustomBehaviour : MonoBehaviour { bool m_isDestroy = false; public bool IsDestroy { get { returnm_isDestroy; } } public virtual void OnDestroy() {}; public void Destroy(UnityEngine.Objectobj) { ......//销毁游戏对象 } }
3.2.1 Update()与LateUpdate()
从可控性和高效性两方面来看,不建议采用逐一遍历游戏对象获取CustomBehaviour的方式去调用Update()与LateUpdate(),而是单独使用列表来管理。
delegate void FrameUpdateFunc(); class FrameUpdate { public FrameUpdateFunc func; public GameObject ower; public CustomBehaviour behaviour; } class MainLoopManager : MonoBehaviour { ...... List m_frameUpdateList; List m_frameLateUpdateList;nn public RegisterFrameUpdate(FrameUpdateFunc func, GameObject owner) public UnRegisterFrameUpdate(FrameUpdateFunc func, GameObject owner) public RegisterFrameLateUpdate(FrameUpdateFunc func, GameObject owner) public UnRegisterFrameLateUpdate(FrameUpdateFunc func, GameObject owner) void Loop() { //先遍历m_frameUpdateList //再遍历m_frameLateUpdateList } ...... }
采取添加删除的方式,对组件是否需要执行Update()与LateUpdate()进行动态地管理,除了具有相对的灵活性,也保证了执行效率。
3.2.2 Invoke相关函数
Invoke、 InvokeRepeating、 CancelInvoke等函数需要使用C#中的反射机制,根据object对象obj和函数名methodName来获取MethodInfo如:
var type = obj.GetType(); MethodInfo method = type.GetMethod(methodName);
通过接口封装,组成相关数据(InvokeData),放入列表等待执行。
class InvokeData { public object obj; public MethodInfo methodInfo; public int delayTime; public int repeatRate; public int repeatFrameAt; public bool isCancel = false; }
如上述结构,delayTime用于记录延迟执行时间,repeatRate代表重复调用的频率,repeatFrameAt则标记上次调用发生的帧序号,而isCancel标记Invoke是否被取消。最后,统一使用MethodBase.Invoke(objectobj, object[] parameters)执行调用。
class MainLoopManager : MonoBehaviour { ...... List m_invokeList; void Loop() { //先遍历m_frameUpdateList //再遍历m_frameLateUpdateList //遍历m_invokeList,并根据相关属性分别进行Invoke、 InvokeRepeating、CancelInvoke } ...... }
3.2.3 协程Coroutine
协程Coroutine较复杂,必需采用的情况较少,本文方案未实现协程Coroutine功能,而是避免使用。
3.2.4 Destroy相关
在Destroy游戏对象或组件后,OnDestroy()将在下一帧执行。因此,需要采取可控的方式代替OnDestroy()函数完成资源的释放。
class CustomBehaviour : MonoBehaviour { bool m_isDestroy = false; public bool IsDestroy { set { m_isDestroy = value; } get { return m_isDestroy; } } public virtual void DoDestroy() {}; public void Destroy(UnityEngine.Object obj) { if (obj.GetType() == typeof(GameObject)) { GameObject go = (GameObject)obj; CustomBehaviour behaviours = go.GetComponents(); for (int i = 0; i < behaviours.Length; i++) { behaviours[i].IsDestroy = true; behaviours[i].DoDestroy(); } } else if (obj.GetType() == typeof(CustomBehaviour)) { CustomBehaviour behaviour = (CustomBehaviour)obj; behaviour.IsDestroy = true; behaviour.DoDestroy(); } UnityEngine.Object.Destroy(obj); } }
3.3 Time类与随机数
帧同步游戏逻辑所有涉及时间的计算都应采用帧时间,即:当前帧序列数 * 服务器帧更新时间 /(填充帧数 + 1),而每帧随机数计算都由服务器下发种子来控制。如下:
class MainLoopManager : MonoBehaviour { ....... int m_serverFrameDelta;//毫秒 int m_curFrameIndex; int m_fillFrameNum; int m_serverRandomSeed; public int serverRandomSeed { get { return m_serverRandomSeed; } } public int curFrameIndex { get { return m_curFrameIndex; } } public static int curFrameTime { return m_curFrameIndex * m_serverFrameDelta / (1 + m_fillFrameNum); } public static int deltaFrameTime { return m_serverFrameDelta / (1 + m_fillFrameNum); } ....... }
可写入CustomBehaviour中,便于自定义Time类的调用,避免误用Unity3D的Time类,Random类同理。
class CustomBehaviour : MonoBehaviour { protected class Time { public static Fix time { get { return (Fix)MainLoopManager.curFrameTime / 1000; } } public static Fix deltaTime { get { return (Fix)MainLoopManager.deltaFrameTime / 1000; } } } protected class Random { public static Fix Range(Fix min, Fix max) { Fix diff = max - min; Fix seed = MainLoopManager.serverRandomSeed; return min + (int)FixMath.Round(diff * (seed / 100)); } } }
其中Fix是定点数,3.4小节会简单描述如何将定点数运用在Unity3D中。本文实现中约定随机种子范围在0-100之间,并采用简单的计算方式。如有特殊需求,自行实现。
3.4 定点数
客户端必须保证对网络帧操作的运算过程和结果一致,然而不同系统平台对浮点数的处理有差别,即便差别甚微,也会造成“蝴蝶效应”,导致不同步现象出现。绝大多数情况下,只需要对游戏对象方位进行定点数改造即可。而Unity3D并非开源游戏引擎,无法对底层transform的position和rotation进行修改。因此,逻辑层计算时需要使用到自定义以定点数为基础的position和rotation,并在每次循环结束之前,将自定义的方位逻辑计算之后所得信息转化Unity3D transform,以便Unity3D更新表现层。使用Unity3D的协程功能Coroutine以及WaitForEndOfFrame()可满足上述需求,即在逻辑层计算完成后,在Unity3D渲染之前更新底层transform的position和rotation。
3.5 网络波动
帧同步机制下,玩家输入发送到网络,所有响应都必须要等网络逻辑帧才能进行处理。理想环境下,网络帧操作接收到的频率是固定的,能保证客户端表现正常不卡顿。但事实是,绝大多数情况下网络都是不稳定的,时快时慢难以预测。最简单的方案就是建立一个网络逻辑帧的缓冲区,设置一个缓冲区上限,当存入缓存区的帧数满足上限之后,按照固定频率播放。若缓冲区变空,等待其重新填满。通过累积网络逻辑帧延迟,平均分布到固定频率,平滑处理了网络波动造成的卡顿。
原文地址:http://gad.qq.com/article/detail/7195472
以上是关于Unity帧同步的实现方法的主要内容,如果未能解决你的问题,请参考以下文章