unity幀同步遊戲極簡框架及實例(附源碼)

閱前提示:
此框架爲有幀同步需求的遊戲做一個簡單的示例,實現了一個精簡的框架,本文着重講解幀同步遊戲開發過程中需要注意的各種要點,伴隨框架自帶了一個小的塔防sample作爲演示.

目錄:

哪些遊戲需要使用幀同步

如果遊戲中有如下需求,那這個遊戲的開發框架應該使用幀同步:

  • 多人實時對戰遊戲
  • 遊戲中需要戰鬥回放功能
  • 遊戲中需要加速功能
  • 需要服務器同步邏輯校驗防止作弊
  • LockStep框架就是爲了上面幾種情況而設計的.

    如何實現一個可行的幀同步框架

    主要確保以下三點來保證幀同步的準確性:

  • 可靠穩定的幀同步基礎算法
  • 消除浮點數帶來的精度誤差
  • 控制好隨機數
  • 幀同步原理

    相同的輸入 + 相同的時機 = 相同的顯示

    客戶端接受的輸入是相同的,執行的邏輯幀也是一樣的,那麼每次得到的結果肯定也是同步一致的。爲了讓運行結果不與硬件運行速度快慢相關聯,則不能用現實歷經的時間(Time.deltaTime)作爲差值閥值進行計算,而是使用固定的時間片段來作爲閥值,這樣無論兩幀之間的真實時間間隔是多少,遊戲邏輯執行的次數是恆定的,舉例:
    我們預設每個邏輯幀的時間跨度是1秒鐘,那麼當物理時間經過10秒後,邏輯便會運行10次,經過100秒便會運行100次,無論在運行速度快的機器上還是慢的機器上均是如此,不會因爲兩幀之間的跨度間隔而有所改變。
    而渲染幀(一般爲30到60幀),則是根據邏輯幀(10到20幀)去插值,從而得到一個“平滑”的展示,渲染幀只是邏輯幀的無限逼近插值,不過人眼一般無法分辨這種滯後性,因此可以把這兩者理解爲同步的.

    如果硬件的運行速度趕不上邏輯幀的運行速度,則有可能出現邏輯執行多次後,渲染才執行一次的狀況,如果遇到這種情況畫面就會出現卡頓和丟幀的情況.

    幀同步算法

    基礎核心算法

    下面這段代碼爲幀同步的核心邏輯片段:

    m_fAccumilatedTime = m_fAccumilatedTime + deltaTime;
    
    //如果真實累計的時間超過遊戲幀邏輯原本應有的時間,則循環執行邏輯,確保整個邏輯的運算不會因爲幀間隔時間的波動而計算出不同的結果
    while (m_fAccumilatedTime > m_fNextGameTime) {
    
        //運行與遊戲相關的具體邏輯
        m_callUnit.frameLockLogic();
    
        //計算下一個邏輯幀應有的時間
        m_fNextGameTime += m_fFrameLen;
    
        //遊戲邏輯幀自增
        GameData.g_uGameLogicFrame += 1;
    }
    
    //計算兩幀的時間差,用於運行補間動畫
    m_fInterpolation = (m_fAccumilatedTime + m_fFrameLen - m_fNextGameTime) / m_fFrameLen;
    
    //更新渲染位置
    m_callUnit.updateRenderPosition(m_fInterpolation);

    渲染更新機制

    由於幀同步以及邏輯與渲染分離的設置,我們不能再去直接操作transform的localPosition,而設立一個虛擬的邏輯值進行代替,我們在遊戲邏輯中,如果需要變更對象的位置,只需要更新這個虛擬的邏輯值,在一輪邏輯計算完畢後會根據這個值統一進行一輪渲染,這裏我們引入了邏輯位置m_fixv3LogicPosition這個變量.

    // 設置位置
    // 
    // @param position 要設置到的位置
    // @return none
    public override void setPosition(FixVector3 position)
    {
        m_fixv3LogicPosition = position;
    }

    渲染流程如下:

    只有需要移動的物體,我們才進行插值運算,不會移動的靜止物體直接設置其座標就可以了

    //只有會移動的對象才需要採用插值算法補間動畫,不會移動的對象直接設置位置即可
    if ((m_scType == "soldier" || m_scType == "bullet") && interpolation != 0)
    {
        m_gameObject.transform.localPosition = Vector3.Lerp(m_fixv3LastPosition.ToVector3(), m_fixv3LogicPosition.ToVector3(), interpolation);
    }
    else
    {
        m_gameObject.transform.localPosition = m_fixv3LogicPosition.ToVector3();
    }

    定點數

    定點數和浮點數,是指在計算機中一個數的小數點的位置是固定的還是浮動的,如果一個數中小數點的位置是固定的,則爲定點數;如果一個數中小數點的位置是浮動的,則爲浮點數。定點數由於小數點的位置固定,因此其精度可控,相反浮點數的精度不可控.

    對於幀同步框架來說,定點數是一個非常重要的特性,我們在在不同平臺,甚至不同手機上運行一段完全相同的代碼時有可能出現截然不同的結果,那是因爲不同平臺不同cpu對浮點數的處理結果有可能是不一致的,遊戲中僅僅0.000000001的精度差距,都可能在多次計算後帶來蝴蝶效應,導致完全不同的結果
    舉例:當一個士兵進入塔的攻擊範圍時,塔會發動攻擊,在手機A上的第100幀時,士兵進入了攻擊範圍,觸發了攻擊,而在手機B上因爲一點點誤差,導致101幀時才觸發攻擊,雖然只差了一幀,但後續會因爲這一幀的偏差帶來之後更多更大的偏差,從這一幀的不同開始,這已經是兩場截然不同的戰鬥了.
    因此我們必須使用定點數來消除精度誤差帶來的不可預知的結果,讓同樣的戰鬥邏輯在任何硬件,任何操作系統下運行都能得到同樣的結果.同時也再次印證文章最開始提到的幀同步核心原理:
    相同的輸入 + 相同的時機 = 相同的顯示
    框架自帶了一套完整的定點數庫Fix64.cs,其中對浮點數與定點數相互轉換,操作符重載都做好了封裝,我們可以像使用普通浮點數那樣來使用定點數

    Fix64 a = (Fix64)1;
    Fix64 b = (Fix64)2;
    Fix64 c = a + b;

    關於定點數的更多相關細節,請參看文後內容:哪些unity數據類型不能直接使用

    關於dotween的正確使用

    提及定點數,我們不得不關注一下項目中常用的dotween這個插件,這個插件功能強大,使用非常方便,讓我們在做動畫時遊刃有餘,但是如果放到幀同步框架中就不能隨便使用了.
    上面提到的浮點數精度問題有可能帶來巨大的影響,而dotween的整個邏輯都是基於時間幀(Time.deltaTime)插值的,而不是基於幀定長插值,因此不能在涉及到邏輯相關的地方使用,只能用在動畫動作渲染相關的地方,比如下面代碼就是不能使用的

    DoLocalMove() function()
        //移動到某個位置後觸發會影響後續判斷的邏輯
        m_fixMoveTime = Fix64.Zero;
    end

    如果只是渲染表現,而與邏輯運算無關的地方,則可以繼續使用dotween.
    我們整個幀框架的邏輯運算中沒有物理時間的概念,一旦邏輯中涉及到真實物理時間,那肯定會對最終計算的結果造成不可預計的影響,因此類似dotween等動畫插件在使用時需要我們多加註意,一個疏忽就會帶來整個邏輯運算結果的不一致.

    隨機數

    遊戲中幾乎很難避免使用隨機數,恰好隨機數也是幀同步框架中一個需要高度關注的注意點,如果每次戰鬥回放產生的隨機數是不一致的,那如何能保證戰鬥結果是一致的呢,因此我們需要對隨機數進行控制,由於不同平臺,不同操作系統對隨機數的處理方式不同,因此我們避免使用平臺自帶的隨機數接口,而是使用自定義的可控隨機數算法SRandom.cs來替代,保證隨機數的產生在跨平臺方面不會出現問題.同時我們需要記錄下每場戰鬥的隨機數種子,只要確定了種子,那產生的隨機數序列就一定是一致的.
    部分代碼片段:

    // range:[min~(max-1)]
    public uint Range(uint min, uint max)
    {
        if (min > max)
            throw new ArgumentOutOfRangeException("minValue", string.Format("'{0}' cannot be greater than {1}.", min, max));
    
        uint num = max - min;
        return Next(num) + min;
    }
    
    public int Next(int max)
    {
        return (int)(Next() % max);
    }

    服務器同步校驗

    服務器校驗和同步運算在現在的遊戲中應用的越來越廣泛,既然要讓服務器運行相關的核心代碼,那麼這部分客戶端與服務器共用的邏輯就有一些需要注意的地方.

  • 邏輯與渲染進行分離
  • 邏輯代碼版本控制策略
  • 避免直接使用Unity特定的數據類型
  • 避免直接調用Unity特定的接口
  • 邏輯和渲染如何進行分離

    服務器是沒有渲染的,它只能執行純邏輯,因此我們的邏輯代碼中如何做到邏輯和渲染完全分離就很重要

    雖然我們在進行模式設計和代碼架構的過程中會盡量做到讓邏輯和渲染解耦,獨立運行(具體實現請參見sample源碼),但出於維護同一份邏輯代碼的考量,我們並沒有辦法完全把部分邏輯代碼進行隔離,因此怎麼識別當前運行環境是客戶端還是服務器就很必要了

    unity給我們提供了自定義宏定義開關的方法,我們可以通過這個開關來判斷當前運行平臺是否爲客戶端,同時關閉服務器代碼中不需要執行的渲染部分

    我們可以在unity中Build Settings–Player Settings–Other Settings中找到Scripting Define Symbols選項,在其中填入

    _CLIENTLOGIC_

    宏定義開關,這樣在unity中我們便可以此作爲是否爲客戶端邏輯的判斷,在客戶端中打開與渲染相關的代碼,同時也讓服務器邏輯不會受到與渲染相關邏輯的干擾,比如:

    #if _CLIENTLOGIC_
            m_gameObject.transform.localPosition = position.ToVector3();
    #endif

    邏輯代碼版本控制策略

  • 版本控制:
    同步校驗的關鍵在於客戶端服務器執行的是完全同一份邏輯源碼,我們應該極力避免源碼來回拷貝的情況出現,因此如何進行版本控制也是需要策略的,在我們公司項目中,需要服務器和客戶端同時運行的代碼是以git子模塊的形式進行管理的,雙端各自有自己的業務邏輯,但子模塊是相同的,這樣維護起來就很方便,推薦大家嘗試.
  • 不同服務器架構如何適配:
    客戶端是c#語言寫的,如果服務器也是採用的c#語言,那正好可以無縫結合,共享邏輯,但目前採用c#作爲遊戲服務器主要語言的項目其實很少,大多是java,c++,golang等,比如我們公司用的是skynet,如果是這種不同語言架構的環境,那我們就需要單獨搭建一個c#服務器了,目前我們的做法是在fedora下結合mono搭建的戰鬥校驗服務器,網關收到戰鬥校驗請求後會轉發到校驗服務器進行戰鬥校驗,把校驗結果返回給客戶端,具體的方式請參閱後文:戰鬥校驗服務器簡單搭建指引
  • 哪些unity數據類型不能直接使用

  • float
  • Vector2
  • Vector3
    上面這三種類型由於都涉及到浮點數,會讓邏輯運行結果不可控,因此都不能在幀同步相關的邏輯代碼中直接使用,用於替代的是在Fix64.cs中定義的定點數類型:
  • 原始數據類型 替代數據類型
    float Fix64
    Vector2 FixVector2
    Vector3 FixVector3

    同時還有一種例外的情況,某些情況下我們會用Vector2來存放int型對象,在客戶端這是沒問題的,因爲int對象不存在精度誤差問題,但是遺憾的是服務器並無法識別Vector2這個unity中的內置數據類型,因此我們不能直接調用,而是需要自己構建一個類似的數據類型,讓構建後的數據類型能夠跨平臺.
    在Fix64.cs中新增了NormalVector2這個數據類型用於替代這些unity原生的數據類型,這樣就可以同時在客戶端和服務器兩端運行同樣的邏輯代碼了.
    那項目中是不是完全沒有float,沒有Vector3這些類型了呢,其實也不完全是,比如設置顏色等API調用還是需要使用float的:

    public void setColor(float r, float g, float b)
    {
    #if _CLIENTLOGIC_
        m_gameObject.GetComponent<SpriteRenderer>().color = new Color(r, g, b, 1);
    #endif
    }

    鑑於項目中既存在浮點數數據類型也存在定點數數據類型,因此在框架中使用了匈牙利命名法進行區分,讓所有參與編碼的人員能一眼分辨出當前變量是浮點數還是定點數

    Fix64 m_fixElapseTime = Fix64.Zero;  //前綴fix代表該變量爲Fix64類型
    public FixVector3 m_fixv3LogicPosition = new FixVector3(Fix64.Zero, Fix64.Zero, Fix64.Zero); //前綴fixv3代表該變量爲FixVector3類型
    float fTime = 0;  //前綴f代表該變量爲float類型

    哪些unity接口不能直接調用

    unity中某些特有的接口不能直接調用,因爲服務器環境下並沒有這些接口,最常見接口有以下幾種:

  • Debug.Log
  • PlayerPrefs
  • Time
    不能直接調用不代表不能用,框架中對這些常用接口封裝到UnityTools.cs,並用上文提到的_CLIENTLOGIC_開關進行控制,
  • public static void Log(object message)
    {
    #if _CLIENTLOGIC_
        UnityEngine.Debug.Log(message);
    #else
        System.Console.WriteLine (message);
    #endif
    }
    
    public static void playerPrefsSetString(string key, string value)
    {
    #if _CLIENTLOGIC_
        PlayerPrefs.SetString(key, value);
    #endif
    }

    這樣在邏輯代碼中調用UnityTools中的接口就可以實現跨平臺了

    UnityTools.Log("end logic frame: " + GameData.g_uGameLogicFrame);

    加速功能

    實現了基礎的幀同步核心功能後,加速功能就很容易實現了,我們只需要改變Time.timeScale這個系統閥值就可以實現.

    //調整戰鬥速度
    btnAdjustSpeed.onClick.AddListener(delegate ()
    {
        if (Time.timeScale == 1)
        {
            Time.timeScale = 2;
            txtAdjustSpeed.text = "2倍速";
        }
        else if (Time.timeScale == 2)
        {
            Time.timeScale = 4;
            txtAdjustSpeed.text = "4倍速";
        }
        else if (Time.timeScale == 4)
        {
            Time.timeScale = 1;
            txtAdjustSpeed.text = "1倍速";
        }
    });

    需要注意的是,由於幀同步的核心原理是在單元片段時間內執行完全相同次數的邏輯運算,從而保證相同輸入的結果一定一致,因此在加速後,物理時間內的計算量跟加速的倍數成正比,同樣的1秒物理時間片段,加速兩倍的計算量是不加速的兩倍,加速10倍的運算量是不加速的10倍,因此我們會發現一些性能比較差的設備在加速後會出現明顯的卡頓和跳幀的狀況,這是CPU運算超負荷的表現,因此需要根據遊戲實際的運算量和表現來確定最大加速倍數,以免加速功能影響遊戲體驗

    小談加速優化

    實際項目中很容易存在加速後卡頓的問題,這是硬件機能決定的,因此如何在加速後進行優化就很重要,最常見的做法是優化美術效果,把一些不太重要的特效,比如打擊效果,buff效果等暫時關掉,加速後會導致各種特效的頻繁創建和銷燬,開銷極大,並且加速後很多細節本來就很難看清楚了,因此根據加速的等級選擇性的屏蔽掉一些不影響遊戲品質的特效是個不錯的思路.由此思路可以引申出一些類似的優化策略,比如停止部分音效的播放,屏蔽實時陰影等小技巧.

    戰鬥回放功能

    通過上面的基礎框架的搭建,我們確保了相同的輸入一定得到相同的結果,那麼戰鬥回放的問題也就變得相對簡單了,我們只需要記錄在某個關鍵遊戲幀觸發了什麼事件就可以了,比如在第100遊戲幀,150遊戲幀分別觸發了出兵事件,那我們在回放的時候進行判斷,當遊戲邏輯幀運行到這兩個關鍵幀時,即調用出兵的API,還原出兵操作,由於操作一致結果必定一致,因此我們就可以看到與原始戰鬥過程完全一致的戰鬥回放了.

    記錄戰鬥關鍵事件

    1.在戰鬥過程中實時記錄

    GameData.battleInfo info = new GameData.battleInfo();
    info.uGameFrame = GameData.g_uGameLogicFrame;
    info.sckeyEvent = "createSoldier";
    GameData.g_listUserControlEvent.Add(info);

    2.戰鬥結束後根據戰鬥過程中實時記錄的信息進行統一保存

    //- 記錄戰鬥信息(回放時使用)
    // 
    // @return none
    void recordBattleInfo() {
        if (false == GameData.g_bRplayMode) {
            //記錄戰鬥數據
            string content = "";
            for (int i = 0; i < GameData.g_listUserControlEvent.Count; i++)
            {
                GameData.battleInfo v = GameData.g_listUserControlEvent[i];
                //出兵
                if (v.sckeyEvent == "createSoldier") {
                    content += v.uGameFrame + "," + v.sckeyEvent + "$";
                }
            }
    
            UnityTools.playerPrefsSetString("battleRecord", content);
            GameData.g_listUserControlEvent.Clear();
        }
    }

    Sample爲了精簡示例流程,戰鬥日誌採用字符串進行存儲,用’$’等作爲切割標識符,實際項目中可根據實際的網絡協議進行制定,比如protobuff,sproto等

    復原戰鬥事件

    1.把戰鬥過程中保存的戰鬥事件進行解碼:

    //- 讀取玩家的操作信息
    // 
    // @return none
    void loadUserCtrlInfo()
    {
        GameData.g_listPlaybackEvent.Clear();
    
        string content = battleRecord;
    
        string[] contents = content.Split('$');
    
        for (int i = 0; i < contents.Length - 1; i++)
        {
            string[] battleInfo = contents[i].Split(',');
    
            GameData.battleInfo info = new GameData.battleInfo();
    
            info.uGameFrame = int.Parse(battleInfo[0]);
            info.sckeyEvent = battleInfo[1];
    
            GameData.g_listPlaybackEvent.Add(info);
        }
    }

    2.根據解碼出來的事件進行邏輯復原:

    //- 檢測回放事件
    // 如果有回放事件則進行回放
    // @param gameFrame 當前的遊戲幀
    // @return none
    void checkPlayBackEvent(int gameFrame)
    {
        if (GameData.g_listPlaybackEvent.Count > 0) {
            for (int i = 0; i < GameData.g_listPlaybackEvent.Count; i++)
            {
                GameData.battleInfo v = GameData.g_listPlaybackEvent[i];
    
                if (gameFrame == v.uGameFrame) {
                    if (v.sckeyEvent == "createSoldier") {
                        createSoldier();
                    }
                }
            }
        }
    }

    框架文件結構


    整個框架中最核心的代碼爲LockStepLogic.cs(幀同步邏輯),Fix64.cs(定點數)和SRandom.cs(隨機數)
    其餘代碼作爲一個示例,如何把核心代碼運用於實際項目中,並且展示了一個稍微複雜的邏輯如何在幀同步框架下良好運行.

  • battle目錄下爲幀同步邏輯以及戰鬥相關的核心代碼
  • battle/core爲戰鬥核心代碼,其中
    -action爲自己實現的移動,延遲等基礎事件
    -base爲基礎對象,所有戰場可見的物體都繼承自基礎對象
    -soldier爲士兵相關
    -state爲狀態機相關
    -tower爲塔相關
  • ui爲戰鬥UI
  • view爲視圖相關
  • 自帶sample流程

    流程:戰鬥—戰鬥結束提交操作步驟進行服務器校驗—接收服務器校驗結果—記錄戰鬥日誌—進行戰鬥回放

  • 綠色部分爲完全相同的戰鬥邏輯
  • 藍色部分爲完全相同的用戶輸入
  • 示例sample中加入了一個非常簡單的socket通信功能,用於將客戶端的操作發送給服務器,服務器根據客戶端的操作進行瞬時回放運算,然後將運算結果發還給客戶端進行比對,這裏只做了一個最簡單的socket功能,力求讓整個sample最精簡化,實際項目中可根據原有的服務器架構進行替換.



    戰鬥校驗服務器簡單搭建指引

  • 安裝mono環境
  • 編譯可執行文件
  • 實現簡單socket通信回傳
  • 安裝mono環境

    進入官網https://www.mono-project.com/download/stable/#download-lin-fedora
    按照指引進行安裝即可

    編譯可執行文件

    1.打開剛纔安裝好的monodeveloper
    2.點擊file->new->solution
    3.在左側的選項卡中選擇Other->.NET
    4.在右側General下選擇Console Project

    在左側工程名上右鍵導入子模塊中battle文件夾下的所有源碼

    點擊build->Rebuild All,如果編譯通過這時會在工程目錄下的obj->x86->Debug文件夾下生成可執行文件
    如果編譯出錯請回看上文提到的各種注意點,排查哪裏出了問題.

    開發過程中發現工程目錄下如果存在git相關的文件會導致monodeveloper報錯關閉,如果遇到這種情況需要將工程目錄下的.git文件夾和.gitmodules文件進行刪除,然後即可正常編譯了.

    運行可執行文件

    cmd打開命令行窗口,切換到剛纔編譯生成的Debug文件目錄下,通過mono命令運行編譯出來的exe可執行文件

    mono LockStepSimpleFramework.exe

    服務器端戰鬥校驗邏輯

    可執行文件生成後並沒有什麼實際用處,因爲還沒有跟我們的戰鬥邏輯發生聯繫,我們需要進行一些小小的修改讓驗證邏輯起作用.
    修改新建工程自動生成的Program.cs文件,加入驗證代碼

    BattleLogic battleLogic = new BattleLogic ();
    battleLogic.init ();
    battleLogic.setBattleRecord (battleRecord);
    battleLogic.replayVideo();
    
    while (true) {
        battleLogic.updateLogic();
        if (battleLogic.m_bIsBattlePause) {
            break;
        }
    }
    Console.WriteLine("m_uGameLogicFrame: " + BattleLogic.s_uGameLogicFrame);

    通過上述代碼我們可以看到,首先構建了一個BattleLogic對象,然後傳入客戶端傳過來的操作日誌(battleRecord),然後用一個while循環在極短的時間內把戰鬥邏輯運算了一次,當判斷到m_bIsBattlePause爲true時證明戰鬥已結束.
    那麼我們最後以什麼作爲戰鬥校驗是否通過的衡量指標呢?很簡單,通過遊戲邏輯幀s_uGameLogicFrame來進行判斷就很準確了,因爲只要有一丁點不一致,都不可能跑出完全相同的邏輯幀數,如果想要更保險一點,還可以加入別的與遊戲業務邏輯具體相關的參數進行判斷,比如殺死的敵人個數,發射了多少顆子彈等等合併作爲綜合判斷依據.

    實現簡單socket通信回傳

    光有戰鬥邏輯校驗還不夠,我們需要加入服務器監聽,接收客戶端發送過來的戰鬥日誌,計算出結果後再回傳給客戶端,框架只實現了一段很簡單的socket監聽和回發消息的功能(儘量將網絡通信流程簡化,因爲大家肯定有自己的一套網絡框架和協議),具體請參看Sample源碼.

    Socket serverSocket = new Socket(SocketType.Stream, ProtocolType.Tcp);
    IPAddress ip = IPAddress.Any;
    IPEndPoint point = new IPEndPoint(ip, 2333);
    //socket綁定監聽地址
    serverSocket.Bind(point);
    Console.WriteLine("Listen Success");
    //設置同時連接個數
    serverSocket.Listen(10);
    
    //利用線程後臺執行監聽,否則程序會假死
    Thread thread = new Thread(Listen);
    thread.IsBackground = true;
    thread.Start(serverSocket);
    
    Console.Read();

    框架源碼

    客戶端

    https://github.com/CraneInForest/LockStepSimpleFramework-Client.git

    服務器

    https://github.com/CraneInForest/LockStepSimpleFramework-Server.git

    客戶端服務器共享邏輯

    https://github.com/CraneInForest/LockStepSimpleFramework-Shared.git

    共享邏輯以子模塊的形式分別加入到客戶端和服務器中,如要運行源碼請在clone完畢主倉庫後再更新一下子模塊,否則沒有共享邏輯是無法通過編譯的

    子模塊更新命令:

    git submodule update --init --recursive

    編譯環境:
    客戶端:win10 + unity5.5.6f1
    服務器:fedora27 64-bit

    發表評論
    所有評論
    還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
    相關文章