天天看點

Unity3D RTS遊戲中幀同步實作

幀同步技術是早期RTS遊戲常用的一種同步技術,本篇文章要給大家介紹的是RTX遊戲中幀同步實作,幀同步是一種前後端資料同步的方式,一般應用于對實時性要求很高的網絡遊戲,想要了解更多幀同步的知識,繼續往下看。

一.背景

幀同步技術是早期RTS遊戲常用的一種同步技術。與狀态同步不同的是,幀同步隻同步操作,其大部分遊戲邏輯都在用戶端上實作,伺服器主要負責廣播和驗證操作,有着邏輯直覺易實作、資料量少、可重播等優點。

部分PC遊戲如帝國時代、魔獸争霸3、星際争霸等,Host(伺服器或某用戶端)隻當接收到所有用戶端在某幀輸入資料後,才會繼續執行,等待直至逾時認為該用戶端掉線。很明顯,當部分用戶端因網絡或裝置問題無法及時上傳操作資料,會影響其它用戶端的表現,造成不好的遊戲體驗。考慮到遊戲公平競争性,這種需要等待的機制是必需的,但并不符合手遊網絡環境的需求。為此,需要使用“樂觀”模式,即是Host采集用戶端上傳操作并按固定頻率廣播已接收到的操作資料,不在乎部分用戶端的操作資料是否上傳成功,且不會影響到其它用戶端的遊戲表現,如圖1所示。

<ignore_js_op>

Unity3D RTS遊戲中幀同步實作

二.剖析Unity3D

幀同步技術最基礎的核心概念就是相同輸入,經過相同計算過程,得出相同計算結果。按照該概念,下面将簡單描述Unity3D實作幀同步時所需要改造的一些方面,Unity3D中腳本生命周期流程圖如圖2所示。

Unity3D RTS遊戲中幀同步實作

幀同步需要避免使用本地計時器相關數值。是以,使用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));
        }
    }
}      

繼續閱讀