媒体号
首页
原创
论坛
产品库
开测表
招聘
首页
原创
论坛
产品库
开测表
招聘
论坛
更多
登录
GameRes游资网在以下平台有媒体号,欢迎关注收藏:
微信公众号
微博
头条号
百家号
网易号
知乎号
开
放
分
享
游戏梦的起点!
投稿邮箱:news@gameres.com
产品邮箱:cp@gameres.com
产业
专访
厂商
观察·投资
产品
政策
电子竞技
直播
区块链
元宇宙
人工智能
平台
手游
页游·H5
端游·PC
主机
虚拟·VR·AR
云游戏
研发
拆解分析
策划
程序·引擎
美术
音乐
测试
市场
职场·创业
运营·渠道
海外
数据·报告
App Store
Steam
其他
硬件·周边
八卦
活动
综合
Unity3D RTS游戏中帧同步实现
GAD
2017-07-14
帧同步技术是早期RTS游戏常用的一种同步技术,本篇文章要给大家介绍的是RTX游戏中帧同步实现,帧同步是一种前后端数据同步的方式,一般应用于对实时性要求很高的网络游戏,想要了解更多帧同步的知识,继续往下看。
一.背景
帧同步技术是早期RTS游戏常用的一种同步技术。与状态同步不同的是,帧同步只同步操作,其大部分游戏逻辑都在客户端上实现,服务器主要负责广播和验证操作,有着逻辑直观易实现、数据量少、可重播等优点。
部分PC游戏如帝国时代、魔兽争霸3、星际争霸等,Host(服务器或某客户端)只当接收到所有客户端在某帧输入数据后,才会继续执行,等待直至超时认为该客户端掉线。很明显,当部分客户端因网络或设备问题无法及时上传操作数据,会影响其它客户端的表现,造成不好的游戏体验。考虑到游戏公平竞争性,这种需要等待的机制是必需的,但并不符合手游网络环境的需求。为此,需要使用“乐观”模式,即是Host采集客户端上传操作并按固定频率广播已接收到的操作数据,不在乎部分客户端的操作数据是否上传成功,且不会影响到其它客户端的游戏表现,如图1所示。
二.剖析Unity3D
帧同步技术最基础的核心概念就是相同输入,经过相同计算过程,得出相同计算结果。按照该概念,下面将简单描述Unity3D实现帧同步时所需要改造的一些方面,Unity3D中脚本生命周期流程图如图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 网络波动
帧同步机制下,玩家输入发送到网络,所有响应都必须要等网络逻辑帧才能进行处理。理想环境下,网络帧操作接收到的频率是固定的,能保证客户端表现正常不卡顿。但事实是,绝大多数情况下网络都是不稳定的,时快时慢难以预测。最简单的方案就是建立一个网络逻辑帧的缓冲区,设置一个缓冲区上限,当存入缓存区的帧数满足上限之后,按照固定频率播放。若缓冲区变空,等待其重新填满。通过累积网络逻辑帧延迟,平均分布到固定频率,平滑处理了网络波动造成的卡顿。
3.6 丢帧处理
由于TCP的丢包重传机制会导致较大的延迟,大多数情况下,帧同步都采用UDP协议进行网络通信,这就意味着需要自行解决丢包问题。
预防:关键帧数据包里携带前面两帧的数据,可大大降低丢包率,但会带来冗余的增加。因此,值得注意的是不能使用UDP数据包过大,否则部分路由器会在组合UPD分组时发生错误,建议不超过Internet标准MTU尺寸576byte。
补救:虽然上述方案能起到预防丢帧的作用,仍然无法避免丢帧问题。在出现丢帧问题时,需要客户端根据所需帧序号主动向服务器请求关键帧,包括单帧请求和批量帧请求。为了保证能够获取到所需关键帧,建议采用TCP协议。
四.结束语
上述内容都是基于《全民XXX》帧同步机制,是对实现过程中所面临难题的总结。在此分享,希望对他人有所帮助。由于作者技术和写作水平有限,若有更好的意见或错误的地方,欢迎指导。
via:GAD
声明:游资网登载此文出于传递信息之目的,绝不意味着游资网赞同其观点或证实其描述。
收藏
评论
评论
侧栏打开
登录
后参与讨论
暂无评论,来抢沙发
相关推荐
UF2025(Shanghai)——《命运扳机》大世界渲染技术深度解析:全局光照与阴影系统的工程实践
聚势增长!Unity 确认参展 2025 ChinaJoy BTOB
UF2025(Shanghai)——移动端渲染管线优化实战:《洛克王国:世界》的技术突破之路
【译】游戏与建筑基本空间
推广
开测游戏
查看更多
奥星热浪
派对游戏
限量删档测试
研发 : 奥星热浪工作室
遮天世界
策略
限量删档测试
发行 : 腾讯
湮灭之潮
PC游戏
限量线下试玩
研发 : 日蚀边缘工作室
雾海之下
搜打撤
限量删档测试
研发 : 网易游戏
蜀山幻想志
仙侠
删档计费测试
发行 : 金鳞互娱
山海烬墟
角色扮演
公测
研发 : 广州乐牛软件科技有限公司
编辑推荐
查看更多
失控进化
遗忘之海
《喇叭之城 (Phonopolis)》
夜幕之下
【爆款新游】【潜力佳作】分析系列
推广
类魂游戏设计专题
推广