1. 程式人生 > >Unity3D中實現幀同步

Unity3D中實現幀同步

這裡寫圖片描述

概覽

在上次實現的幀同步模型當中,遊戲幀率和通訊頻率(也就是幀同步長度)長度是固定間隔的。但實際上,每個玩家的延遲和效能都不同的。在update中會跟蹤兩個變數。第一個是玩家通訊的時長。第二個則是遊戲的效能時長。

移動平均數

為了處理延遲上的波動,我們想快速增加幀同步回合的時長,同時也想在低延遲的時候減少。如果遊戲更新的節奏能夠根據延遲的測量結果自動調節,而不是固定值的話,會使得遊戲玩起來更加順暢。我們可以累加所有的過去資訊得到”移動平均數”,然後根據它作為調節的權重。

每當一個新值大於平均數,我們會設定平均數為新值。這會得到快速增加延遲的行為。當值小於當前平均值,我們會通過權重處理該值,我們有以下公式:

newAverage=currentAverage?(1–w)+newValue?(w)

其中0<w<1

在我的實現中,我設定w=0.1。而且還會跟蹤每個玩家的平均數,而且總是使用所有玩家當中的最大值。這裡是增加新值的方法:

public void Add(int newValue, int playerID) {
    if(newValue > playerAverages[playerID]) {
        //rise quickly
        playerAverages[playerID] = newValue;
    } else {
        //slowly fall down
playerAverages[playerID] = (playerAverages[playerID] * (9) + newValue * (1)) / 10; } }

為了保證計算結果的確定性,計算只使用整數。因此公式調整如下:

newAverage=(currentAverage?(10–w)+newValue?(w))/10

其中0<w<10

而在我的例子中,w=1。

執行時間平均數

每次遊戲幀更新的時間是由執行時間平均數決定的。如果遊戲幀要變得更長,那麼我們需要降低每次幀同步回合更新遊戲幀的次數。另一方面,如果遊戲幀執行得更快了,每次幀同步回合可以更新遊戲幀的次數也多了。對於每次幀同步回合,最長的遊戲幀會被新增到平均數中。每次幀同步回合的第一個遊戲幀都包含了處理動作的時間。這裡使用Stopwatch來計算流逝的時間。

private void ProcessActions() {
    //process action should be considered in runtime performance
    gameTurnSW.Start ();

    ...

    //finished processing actions for this turn, stop the stopwatch
    gameTurnSW.Stop ();
}

private void GameFrameTurn() {
   ...

    //start the stop watch to determine game frame runtime performance
    gameTurnSW.Start();

    //update game
    ...

    GameFrame++;
    if(GameFrame == GameFramesPerLockstepTurn) {
        GameFrame = 0;
    }

    //stop the stop watch, the gameframe turn is over
    gameTurnSW.Stop ();
    //update only if it's larger - we will use the game frame that took the longest in this lockstep turn
    long runtime = Convert.ToInt32 ((Time.deltaTime * 1000))/*deltaTime is in secounds, convert to milliseconds*/ + gameTurnSW.ElapsedMilliseconds;
    if(runtime > currentGameFrameRuntime) {
        currentGameFrameRuntime = runtime;
    }

    //clear for the next frame
    gameTurnSW.Reset();
}

注意到我們也用到了Time.deltaTime。使用這個可能會在遊戲以固定幀率執行的情況下與上一幀時間重疊。但是,我們需要用到它,這使得Unity為我們所做的渲染以及其他事情都是可測量的。這個重疊是可接受的,因為只是需要更大的緩衝區而已。

網路平均數

拿什麼作為網路平均數在這裡不太明確。我最終使用了Stopwatch計算從玩家傳送資料包到玩家確認動作的時間。這個幀同步模型傳送的動作會在未來兩個回合中執行。為了結束幀同步回合,我們需要所有玩家都確認了這個動作。在這之後,我們可能會有兩個動作等待對方確認。為了解決這個問題,用到了兩個Stopwatch。一個用於當前動作,另一個用於上一個動作。這被封裝在ConfirmActions類當中。當幀同步回合往下走,上一個動作的Stopwatch會成為這一個動作的Stopwatch,而舊的”當前動作Stopwatch”會被複用作為新的”上一個動作Stopwatch”。

public class ConfirmedActions
{
...
    public void NextTurn() {
        ...
        Stopwatch swapSW = priorSW;

        //last turns actions is now this turns prior actions
        ...
        priorSW = currentSW;

        //set this turns confirmation actions to the empty array
        ...
        currentSW = swapSW;
        currentSW.Reset ();
    }
}

每當有確認進來,我們會確認我們接收了所有的確認,如果接收到了,那麼就暫停Stopwatch。

public void ConfirmAction(int confirmingPlayerID, int currentLockStepTurn, int confirmedActionLockStepTurn) {
    if(confirmedActionLockStepTurn == currentLockStepTurn) {
        //if current turn, add to the current Turn Confirmation
        confirmedCurrent[confirmingPlayerID] = true;
        confirmedCurrentCount++;
        //if we recieved the last confirmation, stop timer
        //this gives us the length of the longest roundtrip message
        if(confirmedCurrentCount == lsm.numberOfPlayers) {
            currentSW.Stop ();
        }
    } else if(confirmedActionLockStepTurn == currentLockStepTurn -1) {
        //if confirmation for prior turn, add to the prior turn confirmation
        confirmedPrior[confirmingPlayerID] = true;
        confirmedPriorCount++;
        //if we recieved the last confirmation, stop timer
        //this gives us the length of the longest roundtrip message
        if(confirmedPriorCount == lsm.numberOfPlayers) {
            priorSW.Stop ();
        }
    } else {
        //TODO: Error Handling
        log.Debug ("WARNING!!!! Unexpected lockstepID Confirmed : " + confirmedActionLockStepTurn + " from player: " + confirmingPlayerID);
    }
}

傳送平均數

為了讓一個客戶端向其他客戶端傳送平均數,Action介面修改為一個有兩個欄位的抽象類。

[Serializable]
public abstract class Action
{
    public int NetworkAverage { get; set; }
    public int RuntimeAverage { get; set; }

    public virtual void ProcessAction() {}
}

每當處理動作,這些數字會加到執行平均數。然後幀同步回合以及遊戲幀回合開始更新

private void UpdateGameFrameRate() {
    //log.Debug ("Runtime Average is " + runtimeAverage.GetMax ());
    //log.Debug ("Network Average is " + networkAverage.GetMax ());
    LockstepTurnLength = (networkAverage.GetMax () * 2/*two round trips*/) + 1/*minimum of 1 ms*/;
    GameFrameTurnLength = runtimeAverage.GetMax ();

    //lockstep turn has to be at least as long as one game frame
    if(GameFrameTurnLength > LockstepTurnLength) {
        LockstepTurnLength = GameFrameTurnLength;
    }

    GameFramesPerLockstepTurn = LockstepTurnLength / GameFrameTurnLength;
    //if gameframe turn length does not evenly divide the lockstep turn, there is extra time left after the last
    //game frame. Add one to the game frame turn length so it will consume it and recalculate the Lockstep turn length
    if(LockstepTurnLength % GameFrameTurnLength > 0) {
        GameFrameTurnLength++;
        LockstepTurnLength = GameFramesPerLockstepTurn * GameFrameTurnLength;
    }

    LockstepsPerSecond = (1000 / LockstepTurnLength);
    if(LockstepsPerSecond == 0) { LockstepsPerSecond = 1; } //minimum per second

    GameFramesPerSecond = LockstepsPerSecond * GameFramesPerLockstepTurn;

    PerformanceLog.LogGameFrameRate(LockStepTurnID, networkAverage, runtimeAverage, GameFramesPerSecond, LockstepsPerSecond, GameFramesPerLockstepTurn);
}

更新:支援單個玩家

自從本文發出以來,增加了單人模式得支援。

特別感謝redstinggames.com的Dan提供。可以在以下看到修改:Single Player Update diff