Unity3D中實現(xiàn)幀同步(Part 1和Part 2)

第一部分

在幀同步模型中劫侧,每個客戶端都會對整個游戲世界進行模擬瞬内。這種方法的好處在于減少了需要發(fā)送的信息卓箫。幀同步只需要發(fā)送用戶的輸入信息载矿,而對于反過來的中心服務器模型來說,單位的信息則發(fā)送越頻繁越好烹卒。

比如說你在游戲世界中移動角色闷盔。在中心服務器模型中,物理模擬只會在服務器執(zhí)行旅急》旯矗客戶端告訴服務器,角色要往哪個方向移動藐吮。服務器會執(zhí)行尋路而且開始移動角色溺拱。服務器緊接著就會盡可能頻繁地告知每個客戶端該角色的位置逃贝。對于游戲世界中的每個角色都要運行這樣的過程。對于實時策略游戲來說迫摔,同步成千上萬的單位在中心服務器模型中幾乎是不可能的任務沐扳。

在幀同步模型中,在用戶決定移動角色之后,就會告訴所有客戶端。每個客戶端都會執(zhí)行尋路以及更新角色位置阔拳。只有用戶輸入的時候才需要通知每個客戶端,然后每個客戶端都會自己更新物理以及位置杨拐。

這個模型帶來了一些問題。每個客戶端的模擬都必須執(zhí)行得一模一樣擂啥。這意味著哄陶,物理模擬必須執(zhí)行同樣的更新次數(shù)而且每個動作都需要同樣的順序執(zhí)行。如果不這么做哺壶,其中一個客戶端就會跑在其他客戶端之前或者之后屋吨,然后在新的命令發(fā)出之后,跑得太快或者太慢的客戶端走出的路徑就會不同变骡。這些不同會根據(jù)不同的游戲玩法而不同离赫。

另一個問題就是跨不同的機器和平臺的確定性問題。計算上很小的不同都會對游戲造成蝴蝶效應塌碌。這個問題會在后續(xù)的文章中講到。

這里的實現(xiàn)方案靈感來自于這篇文章:《1500個弓箭手》旬盯。每個玩家命令都會在后續(xù)的兩個回合中執(zhí)行台妆。在發(fā)送動作與處理動作之間存在延遲有助于對抗網(wǎng)絡延遲。這個實現(xiàn)還給我們留下了根據(jù)延遲以及機器性能動態(tài)調整每回合時長的空間胖翰。這部分在這里先不討論接剩,會在后續(xù)文章再說。

對于這個實現(xiàn)萨咳,我們有如下定義:

幀同步回合:幀同步回合可以由多個游戲回合組成懊缺。玩家在一個幀同步回合執(zhí)行一個動作。幀同步回合長度會根據(jù)性能調整培他。目前硬編碼為200ms鹃两。

游戲回合:游戲回合就是游戲邏輯和物理模擬的更新。每個幀同步回合擁有的游戲回合次數(shù)是由性能控制的舀凛。目前硬編碼為50ms俊扳,也就是每次幀同步回合有4次游戲回合。也就是每秒有20次游戲回合猛遍。

動作:一個動作就是玩家發(fā)起的一個命令馋记。比如說在某個區(qū)域內選中單位号坡,或者移動選中單位到目的地。

注意:我們將不使用unity3d的物理引擎梯醒。而是使用一個確定性的自定義引擎宽堆。在后續(xù)文章中會有實現(xiàn)。

游戲主循環(huán)

Unity3d的循環(huán)是運行在單線程下的茸习∪蒸铮可以通過在這兩個函數(shù)插入自定義代碼:

Update()

FixedUpdate()

Unity3d的主循環(huán)每次遍歷更新都會調用Update()。主循環(huán)會以最快速度運行逮光,除非設置了固定的幀率代箭。FixedUpdate()會根據(jù)設置每秒執(zhí)行固定次數(shù)。在主循環(huán)遍歷中涕刚,它會被調用零次或多次嗡综,取決于上次遍歷所花費的時間。FixedUpdate()有著我們想要的行為杜漠,就是每次幀同步回合都執(zhí)行固定時長极景。但是,F(xiàn)ixedUpdate()的頻率只能在運行之前設置好驾茴。而我們希望可以根據(jù)性能調節(jié)我們的游戲幀率盼樟。

游戲幀回合

這個實現(xiàn)有著與FixedUpdate()在Update()函數(shù)中執(zhí)行所類似的邏輯。主要不同的地方在于锈至,我們可以調整頻率晨缴。這是通過增加”累計時間”來完成的。每次調用Update()函數(shù)峡捡,上次遍歷所花費的時間會添加到其中击碗。這就是Time.deltaTime。如果累計時間大于我們的固定游戲回合幀率(50ms)们拙,那么我們就會調用gameframe()稍途。我們每次調用gameframe()都會在累計時間上減去50ms,所以我們一直調用砚婆,知道累計時間小于50ms械拍。

private?float?AccumilatedTime?=?0f;

private?float?FrameLength?=?0.05f;?//50?miliseconds

//called?once?per?unity?frame

public?void?Update()?{

//Basically?same?logic?as?FixedUpdate,?but?we?can?scale?it?by?adjusting?FrameLength

AccumilatedTime?=?AccumilatedTime?+?Time.deltaTime;

//in?case?the?FPS?is?too?slow,?we?may?need?to?update?the?game?multiple?times?a?frame

while(AccumilatedTime?>?FrameLength)?{

GameFrameTurn?();

AccumilatedTime?=?AccumilatedTime?-?FrameLength;

}

}

我們跟蹤當前幀同步回合中游戲幀的數(shù)量。每當我們在幀同步回合中達到我們想要的游戲回合次數(shù)装盯,我們就會更新幀同步回合到下一輪坷虑。如果幀同步還不能到下一輪,我們就不能增加游戲幀验夯,而且我們會在下一次同樣執(zhí)行幀同步檢查猖吴。

private?void?GameFrameTurn()?{

//first?frame?is?used?to?process?actions

if(GameFrame?==?0)?{

if(LockStepTurn())?{

GameFrame++;

}

}?else?{

//update?game

//...

GameFrame++;

if(GameFrame?==?GameFramesPerLocksetpTurn)?{

GameFrame?=?0;

}

}

}

在游戲回合中,物理模擬會更新而且我們的游戲邏輯也會更新挥转。游戲邏輯是通過接口(IHasGameFrame)來實現(xiàn)的海蔽,而且添加這個對象到集合中共屈,然后我們就可以進行遍歷。

private?void?GameFrameTurn()?{????//first?frame?is?used?to?process?actions????if(GameFrame?==?0)?{????????if(LockStepTurn())?{????????????GameFrame++;????????}????}?else?{????????//update?game????????SceneManager.Manager.TwoDPhysics.Update?(GameFramesPerSecond);?????????????????Listfinished?=?new?List();

foreach(IHasGameFrame?obj?in?SceneManager.Manager.GameFrameObjects)?{

obj.GameFrameTurn(GameFramesPerSecond);

if(obj.Finished)?{

finished.Add?(obj);

}

}

foreach(IHasGameFrame?obj?in?finished)?{

SceneManager.Manager.GameFrameObjects.Remove?(obj);

}

GameFrame++;

if(GameFrame?==?GameFramesPerLocksetpTurn)?{

GameFrame?=?0;

}

}

}

IHasGameFrame接口有一個方法叫做GameFrameTurn党窜,它以當前每秒游戲幀的個數(shù)為參數(shù)拗引。一個具體的帶游戲邏輯的對象應該基于GameFramesPerSecond來計算。比如說幌衣,如果一個單位正在攻擊另一個單位矾削,而且他攻擊頻率為每秒鐘10點傷害,你可能會通過將它除以GameFramesPerSecond來添加傷害豁护。而GameFramesPerSecond會根據(jù)性能進行調整哼凯。

IHasGameFrame接口也有屬性標記著結束。這使得實現(xiàn)IHasGameFrame的對象可以通知游戲幀循環(huán)自己已經(jīng)結束楚里。一個例子就是一個對象跟著路徑行走断部,而在到達目的地之后,這個對象就不再需要了班缎。

幀同步回合

為了與其他客戶端保持同步蝴光,每次幀同步回合我們都要問以下問題:

我們已經(jīng)收到了所有客戶端的下一輪動作了嗎?

每個客戶端都確認得到我們的動作了嗎达址?

我們有兩個對象蔑祟,ConfirmedActions和PendingActions。這兩個都有各自可能收到消息的集合沉唠。在我們進入下一個回合之前疆虚,我們會檢查這兩個對象。

private?bool?NextTurn()?{

if(confirmedActions.ReadyForNextTurn()?&&?pendingActions.ReadyForNextTurn())?{

//increment?the?turn?ID

LockStepTurnID++;

//move?the?confirmed?actions?to?next?turn

confirmedActions.NextTurn();

//move?the?pending?actions?to?this?turn

pendingActions.NextTurn();

return?true;

}

return?false;

}

動作

動作右冻,也就是命令装蓬,都通過實現(xiàn)IAction接口來通信。有著一個無參數(shù)函數(shù)叫做ProcessAction()纱扭。這個類必須為Serializable。這意味著這個對象的所有字段也是Serializable的儡遮。當用戶與UI交互乳蛾,動作的實例就會創(chuàng)建,然后發(fā)送到我們的幀同步管理器的隊列中鄙币。隊列通常在游戲太慢而用戶在一個幀同步回合中發(fā)送多于一個命令的時候用到肃叶。雖然每次只能發(fā)送一個命令,但沒有一個會忽略十嘿。

當發(fā)送動作到其他玩家的時候因惭,動作實例會序列化為字節(jié)數(shù)組,然后被其他玩家反序列化绩衷。一個默認的”非動作”對象會在用戶沒有執(zhí)行任何操作的時候發(fā)送蹦魔。而其他則會根據(jù)特定游戲邏輯而定激率。這里是一個創(chuàng)建新單位的動作:

using?System;

using?UnityEngine;

[Serializable]

public?class?CreateUnit?:?IAction

{

int?owningPlayer;

int?buildingID;

public?CreateUnit?(int?owningPlayer,?int?buildingID)?{

this.owningPlayer?=?owningPlayer;

this.buildingID?=?buildingID;

}

public?void?ProcessAction()?{

Building?b?=?SceneManager.Manager.GamePieceManager.GetBuilding(owningPlayer,?buildingID);

b.SpawnUnit();

}

}

這個動作會依賴于SceneManager的靜態(tài)引用。如果你不喜歡這個實現(xiàn)勿决,可以修改IAction接口乒躺,使得ProcessAction接收一個SceneManager實例。

實例代碼可以在這里找到:Bitbucket – Sample Lockstep

第二部分

概覽

在上次實現(xiàn)的幀同步模型當中低缩,游戲幀率和通信頻率(也就是幀同步長度)長度是固定間隔的嘉冒。但實際上,每個玩家的延遲和性能都不同的咆繁。在update中會跟蹤兩個變量讳推。第一個是玩家通信的時長。第二個則是游戲的性能時長玩般。

移動平均數(shù)

為了處理延遲上的波動银觅,我們想快速增加幀同步回合的時長,同時也想在低延遲的時候減少壤短。如果游戲更新的節(jié)奏能夠根據(jù)延遲的測量結果自動調節(jié)设拟,而不是固定值的話,會使得游戲玩起來更加順暢久脯。我們可以累加所有的過去信息得到”移動平均數(shù)”纳胧,然后根據(jù)它作為調節(jié)的權重。

每當一個新值大于平均數(shù)帘撰,我們會設置平均數(shù)為新值跑慕。這會得到快速增加延遲的行為。當值小于當前平均值摧找,我們會通過權重處理該值核行,我們有以下公式:

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

其中0

在我的實現(xiàn)中,我設置w=0.1蹬耘。而且還會跟蹤每個玩家的平均數(shù)芝雪,而且總是使用所有玩家當中的最大值。這里是增加新值的方法:

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;

}

}

為了保證計算結果的確定性综苔,計算只使用整數(shù)惩系。因此公式調整如下:

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

其中0

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

運行時間平均數(shù)

每次游戲幀更新的時間是由運行時間平均數(shù)決定的堡牡。如果游戲幀要變得更長,那么我們需要降低每次幀同步回合更新游戲幀的次數(shù)杨刨。另一方面晤柄,如果游戲幀執(zhí)行得更快了,每次幀同步回合可以更新游戲幀的次數(shù)也多了妖胀。對于每次幀同步回合芥颈,最長的游戲幀會被添加到平均數(shù)中惠勒。每次幀同步回合的第一個游戲幀都包含了處理動作的時間。這里使用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捉撮。使用這個可能會在游戲以固定幀率執(zhí)行的情況下與上一幀時間重疊。但是妇垢,我們需要用到它巾遭,這使得Unity為我們所做的渲染以及其他事情都是可測量的。這個重疊是可接受的闯估,因為只是需要更大的緩沖區(qū)而已灼舍。

網(wǎng)絡平均數(shù)

拿什么作為網(wǎng)絡平均數(shù)在這里不太明確。我最終使用了Stopwatch計算從玩家發(fā)送數(shù)據(jù)包到玩家確認動作的時間涨薪。這個幀同步模型發(fā)送的動作會在未來兩個回合中執(zhí)行骑素。為了結束幀同步回合,我們需要所有玩家都確認了這個動作刚夺。在這之后献丑,我們可能會有兩個動作等待對方確認。為了解決這個問題侠姑,用到了兩個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);

}

}

發(fā)送平均數(shù)

為了讓一個客戶端向其他客戶端發(fā)送平均數(shù)妇智,Action接口修改為一個有兩個字段的抽象類确沸。

[Serializable]

public?abstract?class?Action

{

public?int?NetworkAverage?{?get;?set;?}

public?int?RuntimeAverage?{?get;?set;?}

public?virtual?void?ProcessAction()?{}

}

每當處理動作,這些數(shù)字會加到運行平均數(shù)俘陷。然后幀同步回合以及游戲幀回合開始更新

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);

}

更新:支持單個玩家

自從本文發(fā)出以來,增加了單人模式得支持观谦。

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

源代碼:Source code on bitbucket – Dynamic Lockstep Sample

原文:

Lockstep Implementation in Unity3D - Part 1

Lockstep Implementation in Unity3D - Part 2

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末豁状,一起剝皮案震驚了整個濱河市捉偏,隨后出現(xiàn)的幾起案子倒得,更是在濱河造成了極大的恐慌,老刑警劉巖夭禽,帶你破解...
    沈念sama閱讀 221,198評論 6 514
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件霞掺,死亡現(xiàn)場離奇詭異,居然都是意外死亡讹躯,警方通過查閱死者的電腦和手機菩彬,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,334評論 3 398
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來潮梯,“玉大人骗灶,你說我怎么就攤上這事”螅” “怎么了耙旦?”我有些...
    開封第一講書人閱讀 167,643評論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長萝究。 經(jīng)常有香客問我免都,道長,這世上最難降的妖魔是什么帆竹? 我笑而不...
    開封第一講書人閱讀 59,495評論 1 296
  • 正文 為了忘掉前任绕娘,我火速辦了婚禮,結果婚禮上馆揉,老公的妹妹穿的比我還像新娘业舍。我一直安慰自己,他們只是感情好升酣,可當我...
    茶點故事閱讀 68,502評論 6 397
  • 文/花漫 我一把揭開白布舷暮。 她就那樣靜靜地躺著,像睡著了一般噩茄。 火紅的嫁衣襯著肌膚如雪下面。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,156評論 1 308
  • 那天绩聘,我揣著相機與錄音沥割,去河邊找鬼。 笑死凿菩,一個胖子當著我的面吹牛机杜,可吹牛的內容都是我干的。 我是一名探鬼主播衅谷,決...
    沈念sama閱讀 40,743評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼椒拗,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起蚀苛,我...
    開封第一講書人閱讀 39,659評論 0 276
  • 序言:老撾萬榮一對情侶失蹤在验,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后堵未,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體腋舌,經(jīng)...
    沈念sama閱讀 46,200評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 38,282評論 3 340
  • 正文 我和宋清朗相戀三年渗蟹,在試婚紗的時候發(fā)現(xiàn)自己被綠了块饺。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,424評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡拙徽,死狀恐怖刨沦,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情膘怕,我是刑警寧澤想诅,帶...
    沈念sama閱讀 36,107評論 5 349
  • 正文 年R本政府宣布,位于F島的核電站岛心,受9級特大地震影響来破,放射性物質發(fā)生泄漏。R本人自食惡果不足惜忘古,卻給世界環(huán)境...
    茶點故事閱讀 41,789評論 3 333
  • 文/蒙蒙 一徘禁、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧髓堪,春花似錦送朱、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,264評論 0 23
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至争群,卻和暖如春回怜,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背换薄。 一陣腳步聲響...
    開封第一講書人閱讀 33,390評論 1 271
  • 我被黑心中介騙來泰國打工玉雾, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人轻要。 一個月前我還...
    沈念sama閱讀 48,798評論 3 376
  • 正文 我出身青樓复旬,卻偏偏與公主長得像,于是被迫代替她去往敵國和親冲泥。 傳聞我的和親對象是個殘疾皇子赢底,可洞房花燭夜當晚...
    茶點故事閱讀 45,435評論 2 359

推薦閱讀更多精彩內容