幀同步技術是早期RTS遊戲常用的一種同步技術,本篇文章要給大家介紹的是RTX遊戲中幀同步實作,幀同步是一種前後端資料同步的方式,一般應用于對實時性要求很高的網絡遊戲,想要了解更多幀同步的知識,繼續往下看。
一.背景
幀同步技術是早期RTS遊戲常用的一種同步技術。與狀态同步不同的是,幀同步隻同步操作,其大部分遊戲邏輯都在用戶端上實作,伺服器主要負責廣播和驗證操作,有着邏輯直覺易實作、資料量少、可重播等優點。
部分PC遊戲如帝國時代、魔獸争霸3、星際争霸等,Host(伺服器或某用戶端)隻當接收到所有用戶端在某幀輸入資料後,才會繼續執行,等待直至逾時認為該用戶端掉線。很明顯,當部分用戶端因網絡或裝置問題無法及時上傳操作資料,會影響其它用戶端的表現,造成不好的遊戲體驗。考慮到遊戲公平競争性,這種需要等待的機制是必需的,但并不符合手遊網絡環境的需求。為此,需要使用“樂觀”模式,即是Host采集用戶端上傳操作并按固定頻率廣播已接收到的操作資料,不在乎部分用戶端的操作資料是否上傳成功,且不會影響到其它用戶端的遊戲表現,如圖1所示。
<ignore_js_op>
二.剖析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);
}
.......
}
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));
}
}
}