Unity3D RTX游戏中帧同步实现
帧同步技术是早期RTS游戏常用的一种同步技术。本文将详细介绍RTX游戏中帧同步的实现,帧同步作为一种前后端数据同步的方式,常用于对实时性要求极高的网络游戏。若想深入了解帧同步知识,可继续阅读下文。
一、背景
帧同步技术在早期RTS游戏中广泛应用。与状态同步不同,帧同步仅同步操作,大部分游戏逻辑在客户端实现,服务器主要负责广播和验证操作,具有逻辑直观易实现、数据量少、可重播等优点。
部分PC游戏,如《帝国时代》《魔兽争霸3》《星际争霸》等,Host(服务器或某客户端)需在接收到所有客户端某帧的输入数据后才会继续执行,若超时未收到则认为该客户端掉线。显然,当部分客户端因网络或设备问题无法及时上传操作数据时,会影响其他客户端的表现,导致游戏体验不佳。考虑到游戏的公平竞争性,这种等待机制虽有必要,但不符合手游网络环境的需求。因此,需采用“乐观”模式,即Host采集客户端上传操作,并按固定频率广播已接收到的操作数据,不关心部分客户端的操作数据是否上传成功,且不会影响其他客户端的游戏表现,如图1所示。
二、剖析Unity3D
帧同步技术的核心概念是相同输入经过相同计算过程得出相同计算结果。基于此概念,下面将阐述使用Unity3D实现帧同步时需要改造的方面,Unity3D中脚本生命周期流程图如图2所示。
在使用Unity3D实现帧同步的过程中,需要注意以下几点:
- 禁用Time类相关属性及函数:例如Time.deltaTime等,改用帧时间(第N帧 X 固定频率)。
- 禁用Invoke()等函数:避免使用此类与本地更新相关的函数。
- 避免在特定函数中实现影响游戏逻辑判断的代码:如Awake()、Start()、Update()、LateUpdate()、OnDestroy()等函数,这些函数的调用时机与本地更新相关,不满足帧同步机制的要求。
- 避免使用Unity3D自带物理引擎:不同系统平台对物理引擎的处理可能存在差异,会影响帧同步的一致性。
- 避免使用协程Coroutine:协程的调用时机和执行过程较复杂,不利于帧同步的实现。
三、具体实现
3.1 相关定义
- 关键帧:服务器按固定频率广播的操作数据帧,使用唯一ID标识,主要包含客户端输入数据或服务器发送的关键信息(如游戏开始或结束等消息)。
- 填充帧:由于设备性能和网络延迟等因素,服务器广播频率难以达到客户端的更新频率。若仅使用关键帧驱动游戏运行,会导致游戏卡顿,影响体验。因此,客户端需自行添加若干空数据帧,以保证游戏表现更加流畅。
- 逻辑帧更新时间:客户端执行一帧所需的时间,可根据设备性能和网络环境等因素动态变化。
- 服务器帧更新时间:服务器广播帧数据的固定频率,通常用于帧间隔时间差的逻辑计算。
3.2 主循环
帧同步要求相同的计算过程,这涉及两个方面:一是顺序一致,由于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.3 自定义MonoBehaviour
Unity3D脚本生命周期中的部分函数、Invoke、Coroutine调用时机与本地更新相关,无法满足帧同步机制的要求。我们通过继承MonoBehaviour类来实现这些函数和功能需求,并让所有涉及逻辑计算的组件都继承该自定义类。
class CustomBehaviour : MonoBehaviour
{
bool m_isDestroy = false;
public bool IsDestroy
{
get { return m_isDestroy; }
}
public virtual void OnDestroy() {};
public void Destroy(UnityEngine.Object obj)
{
// 销毁游戏对象
}
}
3.3.1 Update()与LateUpdate()
从可控性和高效性考虑,不建议逐一遍历游戏对象获取CustomBehaviour来调用Update()与LateUpdate(),而是使用单独的列表进行管理。
delegate void FrameUpdateFunc();
class FrameUpdate
{
public FrameUpdateFunc func;
public GameObject ower;
public CustomBehaviour behaviour;
}
class MainLoopManager : MonoBehaviour
{
// ...
List<FrameUpdate> m_frameUpdateList;
List<FrameUpdate> m_frameLateUpdateList;
public void RegisterFrameUpdate(FrameUpdateFunc func, GameObject owner)
public void UnRegisterFrameUpdate(FrameUpdateFunc func, GameObject owner)
public void RegisterFrameLateUpdate(FrameUpdateFunc func, GameObject owner)
public void UnRegisterFrameLateUpdate(FrameUpdateFunc func, GameObject owner)
void Loop()
{
// 先遍历m_frameUpdateList
// 再遍历m_frameLateUpdateList
}
// ...
}
通过添加和删除的方式动态管理组件是否执行Update()与LateUpdate(),既保证了灵活性,又提高了执行效率。
3.3.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(object obj, object[] parameters)执行调用。
class MainLoopManager : MonoBehaviour
{
// ...
List<InvokeData> m_invokeList;
void Loop()
{
// 先遍历m_frameUpdateList
// 再遍历m_frameLateUpdateList
// 遍历m_invokeList,并根据相关属性分别进行Invoke、InvokeRepeating、CancelInvoke
}
// ...
}
3.3.3 协程Coroutine
协程Coroutine较为复杂,在大多数情况下并非必需,本文方案未实现协程Coroutine功能,而是选择避免使用。
3.3.4 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<CustomBehaviour>();
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.4 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是定点数,下一小节将简单介绍如何在Unity3D中运用定点数。本文实现中约定随机种子范围在0 - 100之间,并采用简单的计算方式,如有特殊需求,可自行实现。
3.5 定点数
客户端必须确保对网络帧操作的运算过程和结果一致,但不同系统平台对浮点数的处理存在差异,即使差异微小,也可能引发“蝴蝶效应”,导致不同步现象。在大多数情况下,只需对游戏对象方位进行定点数改造。由于Unity3D并非开源游戏引擎,无法修改底层transform的position和rotation,因此在逻辑层计算时,需要使用自定义的以定点数为基础的position和rotation,并在每次循环结束前,将自定义方位逻辑计算后的信息转换为Unity3D transform,以便Unity3D更新表现层。使用Unity3D的协同功能Coroutine以及WaitForEndOfFrame()可满足上述需求,即在逻辑层计算完成后,在Unity3D渲染之前更新底层transform的position和rotation。
3.6 网络波动
在帧同步机制下,玩家输入发送到网络后,所有响应都需等待网络逻辑帧才能处理。理想情况下,网络帧操作接收频率固定,可保证客户端表现正常不卡顿。但实际上,网络大多不稳定,难以预测。最简单的解决方案是建立一个网络逻辑帧的缓冲区,并设置缓冲区上限。当存入缓冲区的帧数达到上限后,按照固定频率播放;若缓冲区变空,则等待其重新填满。通过累积网络逻辑帧延迟并平均分布到固定频率,可平滑处理网络波动造成的卡顿。
3.7 丢帧处理
由于TCP的丢包重传机制会导致较大延迟,大多数情况下,帧同步采用UDP协议进行网络通信,这就需要自行解决丢包问题。
- 预防:关键帧数据包携带前面两帧的数据,可显著降低丢包率,但会增加数据冗余。因此,需注意UDP数据包不宜过大,否则部分路由器在组合UDP分组时可能出错,建议不超过Internet标准MTU尺寸576byte。
- 补救:尽管上述预防措施可降低丢包率,但仍无法完全避免丢帧问题。当出现丢帧时,客户端需根据所需帧序号主动向服务器请求关键帧,包括单帧请求和批量帧请求。为确保能获取所需关键帧,建议采用TCP协议。
四、结束语
上述内容基于《全民XXX》帧同步机制,总结了实现过程中遇到的难题及解决方案。