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