GameRes游资网

 找回密码
 立即注册

Unity3D RTS游戏中帧同步实现

发布者: Chengsoul | 发布时间: 2017-7-14 15:33| 评论数: 3

游戏程序
平台类型: PC/兼容机 
程序设计: 设计思想/框架 
编程语言:  
引擎/SDK: Unity3D 
文/Enuma

帧同步技术是早期RTS游戏常用的一种同步技术,本篇文章要给大家介绍的是RTX游戏中帧同步实现,帧同步是一种前后端数据同步的方式,一般应用于对实时性要求很高的网络游戏,想要了解更多帧同步的知识,继续往下看。

一.背景

帧同步技术是早期RTS游戏常用的一种同步技术。与状态同步不同的是,帧同步只同步操作,其大部分游戏逻辑都在客户端上实现,服务器主要负责广播和验证操作,有着逻辑直观易实现、数据量少、可重播等优点。

部分PC游戏如帝国时代、魔兽争霸3、星际争霸等,Host(服务器或某客户端)只当接收到所有客户端在某帧输入数据后,才会继续执行,等待直至超时认为该客户端掉线。很明显,当部分客户端因网络或设备问题无法及时上传操作数据,会影响其它客户端的表现,造成不好的游戏体验。考虑到游戏公平竞争性,这种需要等待的机制是必需的,但并不符合手游网络环境的需求。为此,需要使用“乐观”模式,即是Host采集客户端上传操作并按固定频率广播已接收到的操作数据,不在乎部分客户端的操作数据是否上传成功,且不会影响到其它客户端的游戏表现,如图1所示。

image001.png

二.剖析Unity3D

帧同步技术最基础的核心概念就是相同输入,经过相同计算过程,得出相同计算结果。按照该概念,下面将简单描述Unity3D实现帧同步时所需要改造的一些方面,Unity3D中脚本生命周期流程图如图2所示。

image003.jpg

帧同步需要避免使用本地计时器相关数值。因此,使用Unity3D实现帧同步的过程所需注意的几点:

1. 禁用Time类相关属性及函数,如Time.deltaTime等。而使用帧时间(第N帧 X 固定频率)

2. 禁用Invoke()等函数

3. 避免在Awake()、Start()、Update()、LateUpdate()、OnDestroy()等函数中实现影响游戏逻辑判断的代码

4. 避免使用Unity3D自带物理引擎

5. 避免使用协程Coroutine

三.具体实现

对于本文的实现,有如下定义:

关键帧:服务器按固定频率广播的操作数据帧,使用唯一ID标识,主要包括客户端输入数据或服务器发送的关键信息(例如游戏开始或结束等消息)

填充帧:由于设备性能和网络延迟等原因,服务器广播频率不可能达到客户端的更新频率。若只使用关键帧来驱动游戏运作,就会造成游戏卡顿,影响体验。因此,除关键帧外,客户端需要自行添加若干空数据帧,以使游戏表现更为流畅

逻辑帧更新时间:客户端执行一帧所需时间,可根据设备性能和网络环境等因素动态变化

服务器帧更新时间:服务器广播帧数据的固定频率,一般用于帧间隔时间差的逻辑计算

3.1 主循环

帧同步要求相同的计算过程,这就涉及到两个方面,其一是顺序一致,Unity3D主循环不可控,需自定义游戏循环,统一管理游戏对象以及脚本的执行,确保所有对象更新与逻辑执行顺序完全一致。另一方面是结果一致,凡有浮点数参与的逻辑计算需要特殊处理。
  1. class MainLoopManager : MonoBehaviour
  2. {
  3.     bool m_start;
  4.     int m_logicFrameDelta;//逻辑帧更新时间
  5.     int m_logicFrameAdd;//累积时间

  6.     void Loop()
  7.     {
  8.         ......//遍历所有脚本
  9.     }

  10.     void Update()
  11.     {
  12.         if (!m_start)
  13.             return;

  14.         if (m_logicFrameAdd < m_logicFrameDelta)
  15.         {
  16.             m_logicFrameAdd += (int)(Time.deltaTime * 1000);
  17.         }
  18.         else
  19.         {
  20.             int frameNum = 0;
  21.             while(CanUpdateNextFrame() || IsFillFrame())
  22.             {
  23.                 Loop();//主循环
  24.                 frameNum++;
  25.                 if (frameNum > 10)
  26.                 {
  27.                     //最多连续播放10帧
  28.                     break;
  29.                 }
  30.             }
  31.             m_logicFrameAdd = 0;
  32.         }
  33.     }

  34.     bool CanUpdateNextFrame();//是否可以更新至下一关键帧
  35.     bool IsFillFrame();//当前逻辑帧是否为填充帧
  36. }
复制代码


3.2 自定义MonoBehaviour

Unity3D脚本生命周期中部分函数、Invoke、Coroutine调用时机与本地更新相关,并不满足帧同步机制的要求。我们通过继承MonoBehaviour类来实现上述函数和功能需求,并使所有涉及逻辑计算的组件都继承该自定义类。

  1. class CustomBehaviour : MonoBehaviour
  2. {
  3.     bool m_isDestroy = false;

  4.     public bool IsDestroy
  5.     {
  6.         get { returnm_isDestroy; }
  7.     }

  8.     public virtual void OnDestroy() {};
  9.      
  10.     public void Destroy(UnityEngine.Objectobj)
  11.     {

  12.         ......//销毁游戏对象

  13.     }
  14. }
复制代码

3.2.1 Update()与LateUpdate()

从可控性和高效性两方面来看,不建议采用逐一遍历游戏对象获取CustomBehaviour的方式去调用Update()与LateUpdate(),而是单独使用列表来管理。

  1. delegate void FrameUpdateFunc();
  2. class FrameUpdate
  3. {
  4.     public FrameUpdateFunc func;
  5.     public GameObject ower;
  6.     public CustomBehaviour behaviour;
  7. }

  8. class MainLoopManager : MonoBehaviour
  9. {
  10.     ......
  11.     List m_frameUpdateList;
  12.     List m_frameLateUpdateList;nn

  13.     public RegisterFrameUpdate(FrameUpdateFunc func, GameObject owner)
  14.     public UnRegisterFrameUpdate(FrameUpdateFunc func, GameObject owner)
  15.     public RegisterFrameLateUpdate(FrameUpdateFunc func, GameObject owner)
  16.     public UnRegisterFrameLateUpdate(FrameUpdateFunc func, GameObject owner)
  17. void Loop()
  18.     {
  19.         //先遍历m_frameUpdateList
  20.         //再遍历m_frameLateUpdateList
  21.     }
  22.     ......
  23. }
复制代码

采取添加删除的方式,对组件是否需要执行Update()与LateUpdate()进行动态地管理,除了具有相对的灵活性,也保证了执行效率。

3.2.2 Invoke相关函数

Invoke、 InvokeRepeating、 CancelInvoke等函数需要使用C#中的反射机制,根据object对象obj和函数名methodName来获取MethodInfo如:

  1. var type = obj.GetType();
  2. MethodInfo method = type.GetMethod(methodName);
复制代码

通过接口封装,组成相关数据(InvokeData),放入列表等待执行。

  1. class InvokeData
  2. {
  3.     public object obj;
  4.     public MethodInfo methodInfo;
  5.     public int delayTime;
  6.     public int repeatRate;
  7.     public int repeatFrameAt;
  8.     public bool isCancel = false;
  9. }
复制代码

如上述结构,delayTime用于记录延迟执行时间,repeatRate代表重复调用的频率,repeatFrameAt则标记上次调用发生的帧序号,而isCancel标记Invoke是否被取消。最后,统一使用MethodBase.Invoke(objectobj, object[] parameters)执行调用。

  1. class MainLoopManager : MonoBehaviour
  2. {
  3.     ......
  4.     List m_invokeList;

  5.     void Loop()
  6.     {
  7.         //先遍历m_frameUpdateList
  8.         //再遍历m_frameLateUpdateList
  9.         //遍历m_invokeList,并根据相关属性分别进行Invoke、 InvokeRepeating、CancelInvoke
  10.     }
  11.     ......
  12. }
复制代码

3.2.3 协程Coroutine

协程Coroutine较复杂,必需采用的情况较少,本文方案未实现协程Coroutine功能,而是避免使用。

3.2.4 Destroy相关

在Destroy游戏对象或组件后,OnDestroy()将在下一帧执行。因此,需要采取可控的方式代替OnDestroy()函数完成资源的释放。

  1. class CustomBehaviour : MonoBehaviour
  2. {
  3.     bool m_isDestroy = false;
  4.     public bool IsDestroy
  5.     {
  6.         set { m_isDestroy = value; }
  7.         get { return m_isDestroy; }
  8.     }
  9.     public virtual void DoDestroy() {};
  10.     public void Destroy(UnityEngine.Object obj)
  11.     {
  12.         if (obj.GetType() == typeof(GameObject))
  13.         {
  14.             GameObject go = (GameObject)obj;
  15.             CustomBehaviour behaviours = go.GetComponents();
  16.             for (int i = 0; i < behaviours.Length; i++)
  17.             {
  18.                 behaviours[i].IsDestroy = true;
  19.                 behaviours[i].DoDestroy();
  20.             }
  21.         }
  22.         else if (obj.GetType() == typeof(CustomBehaviour))
  23.         {
  24.             CustomBehaviour behaviour = (CustomBehaviour)obj;
  25.             behaviour.IsDestroy = true;
  26.             behaviour.DoDestroy();
  27.         }
  28.         UnityEngine.Object.Destroy(obj);
  29.     }
  30. }
复制代码

3.3 Time类与随机数

帧同步游戏逻辑所有涉及时间的计算都应采用帧时间,即:当前帧序列数 * 服务器帧更新时间 /(填充帧数 + 1),而每帧随机数计算都由服务器下发种子来控制。如下:

  1. class MainLoopManager : MonoBehaviour
  2. {
  3.     .......
  4.     int m_serverFrameDelta;//毫秒
  5.     int m_curFrameIndex;
  6.     int m_fillFrameNum;
  7.     int m_serverRandomSeed;

  8.     public int serverRandomSeed
  9.     {
  10.         get { return m_serverRandomSeed; }
  11.     }
  12.     public int curFrameIndex
  13.     {
  14.         get { return m_curFrameIndex; }
  15.     }
  16.     public static int curFrameTime
  17.     {
  18.         return m_curFrameIndex * m_serverFrameDelta / (1 + m_fillFrameNum);
  19.     }
  20.     public static int deltaFrameTime
  21.     {
  22.         return m_serverFrameDelta / (1 + m_fillFrameNum);
  23.     }
  24.     .......
  25. }
复制代码

可写入CustomBehaviour中,便于自定义Time类的调用,避免误用Unity3D的Time类,Random类同理。

  1. class CustomBehaviour : MonoBehaviour
  2. {
  3.     protected class Time
  4.     {
  5.         public static Fix time
  6.         {
  7.             get { return (Fix)MainLoopManager.curFrameTime / 1000; }
  8.         }

  9.         public static Fix deltaTime
  10.         {
  11.             get { return (Fix)MainLoopManager.deltaFrameTime / 1000; }
  12.         }
  13.     }

  14.     protected class Random
  15.     {
  16.         public static Fix Range(Fix min, Fix max)
  17.         {
  18.             Fix diff = max - min;
  19.             Fix seed = MainLoopManager.serverRandomSeed;
  20.             return min + (int)FixMath.Round(diff * (seed / 100));
  21.         }
  22.     }
  23. }
复制代码

其中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 网络波动

帧同步机制下,玩家输入发送到网络,所有响应都必须要等网络逻辑帧才能进行处理。理想环境下,网络帧操作接收到的频率是固定的,能保证客户端表现正常不卡顿。但事实是,绝大多数情况下网络都是不稳定的,时快时慢难以预测。最简单的方案就是建立一个网络逻辑帧的缓冲区,设置一个缓冲区上限,当存入缓存区的帧数满足上限之后,按照固定频率播放。若缓冲区变空,等待其重新填满。通过累积网络逻辑帧延迟,平均分布到固定频率,平滑处理了网络波动造成的卡顿。

3.6 丢帧处理

由于TCP的丢包重传机制会导致较大的延迟,大多数情况下,帧同步都采用UDP协议进行网络通信,这就意味着需要自行解决丢包问题。

预防:关键帧数据包里携带前面两帧的数据,可大大降低丢包率,但会带来冗余的增加。因此,值得注意的是不能使用UDP数据包过大,否则部分路由器会在组合UPD分组时发生错误,建议不超过Internet标准MTU尺寸576byte。

补救:虽然上述方案能起到预防丢帧的作用,仍然无法避免丢帧问题。在出现丢帧问题时,需要客户端根据所需帧序号主动向服务器请求关键帧,包括单帧请求和批量帧请求。为了保证能够获取到所需关键帧,建议采用TCP协议。

四.结束语

上述内容都是基于《全民XXX》帧同步机制,是对实现过程中所面临难题的总结。在此分享,希望对他人有所帮助。由于作者技术和写作水平有限,若有更好的意见或错误的地方,欢迎指导。

via:GAD

声明:游资网登载此文出于传递信息之目的,绝不意味着游资网赞同其观点或证实其描述。


最新评论

尊尊 发表于 2017-7-20 09:39:20
大神,加你好友了但是还没权限发消息,能加个QQ聊一下吗?
Aud1o 发表于 2017-8-8 19:24:02
好牛逼。。。。
xiao1992 发表于 2017-10-15 09:51:51
不错  先占一个沙发  谢谢
  • 腾讯天美”王牌制作人”李旻谈游戏设计
  • 大型狩猎ARPG游戏《无畏》,拳头前员工打造
  • 《暗黑2》经典数值公式分析总结(二)
  • 关于游戏数值,你应该知道的几件事
  • 为什么很多国内的创意游戏设计得很糟糕?
  • 不拿版权、不分流水、还掏钱导量推产品 “

小黑屋|稿件投递|广告合作|关于本站|GameRes游资网 ( 闽ICP备05005107-1 )

GMT+8, 2018-7-18 22:12

快速回复 返回顶部 返回列表