101256i7fufphhw7888pu6.jpg
文/云風

最近讀了一篇《守望先鋒》架構(gòu)設(shè)計與網(wǎng)絡(luò)同步。這是根據(jù)GDC 2017上的演講Overwatch Gameplay Architecture and Netcode視頻翻譯而來的,所以并沒有原文。由于是個一小時的演講,不可能講得面面俱到,所以理解起來有些困難,我反復(fù)讀了三遍,然后把英文視頻找來(訂閱GDC Vault可以看,有版權(quán))看了一遍,大致理解了ECS這個框架。寫這篇Blog記錄一下我對ECS的理解,結(jié)合我自己這些年做游戲開發(fā)的經(jīng)驗,可能并非等價于原演講中的思想。

onent System(ECS)是一個gameplay層面的框架,它是建立在渲染引擎、物理引擎之上的,主要解決的問題是如何建立一個模型來處理游戲?qū)ο?Game Object)的更新操作。

傳統(tǒng)的很多游戲引擎是基于面向?qū)ο髞碓O(shè)計的,游戲中的東西都是對象,每個對象有一個叫做Update的方法,框架遍歷所有的對象,依次調(diào)用其Update方法。有些引擎甚至定義了多種Update方法,在同一幀的不同時機去調(diào)用。

這么做其實是有極大的缺陷的,我相信很多做過游戲開發(fā)的程序都會有這種體會。因為游戲?qū)ο笃鋵嵤怯珊芏嗖糠志酆隙?,引擎的功能模塊很多,不同的模塊關(guān)注的部分往往互不相關(guān)。比如渲染模塊并不關(guān)心網(wǎng)絡(luò)連接、游戲業(yè)務(wù)處理不關(guān)心玩家的名字、用的什么模型。從自然意義上說,把游戲?qū)ο蟮膶傩跃酆显谝黄鸪蔀橐粋€對象是很自然的事情,對于這個對象的生命期管理也是最合理的方式。但對于不同的業(yè)務(wù)模塊來說,針對聚合在一起的對象做處理,把處理方法綁定在對象身上就不那么自然了。這會導(dǎo)致模塊的內(nèi)聚性很差、模塊間也會出現(xiàn)不必要的耦合。

我覺得守望先鋒之所以要設(shè)計一個新的框架來解決這個問題,是因為他們面對的問題復(fù)雜度可能到了一個更高的程度:比如如何用預(yù)測技術(shù)做更準確的網(wǎng)絡(luò)同步。網(wǎng)絡(luò)同步只關(guān)心很少的對象屬性,沒必要在設(shè)計同步模塊時牽扯過多不必要的東西。為了準確,需要讓客戶端和服務(wù)器跑同一套代碼,而服務(wù)器并不需要做顯示,所以要比較容易的去掉顯示系統(tǒng);客戶端和服務(wù)器也不完全是同樣的邏輯,需要共享一部分系統(tǒng),而在另一部分上根據(jù)分別實現(xiàn)……

總的來說、需要想一個辦法拆分復(fù)雜問題,把問題聚焦到一個較小的集合,提高每個子任務(wù)的內(nèi)聚性。

ECS的E,也就是Entity,可以說就是傳統(tǒng)引擎中的Game Object。但在這個系統(tǒng)下,它僅僅是C/Component的組合。它的意義在于生命期管理,這里是用32bit ID而不是指針來表示的,另外附著了渲染用到的資源ID。因為僅負責生命期管理,而不設(shè)計調(diào)用其上的方法,用整數(shù)ID更健壯。整數(shù)ID更容易指代一個無效的對象,而指針就很難做到。

C和S是這個框架的核心。System系統(tǒng),也就是我上面提到的模塊。對于游戲來說,每個模塊應(yīng)該專注于干好一件事,而每件事要么是作用于游戲世界里同類的一組對象的每單個個體的,要么是關(guān)心這類對象的某種特定的交互行為。比如碰撞系統(tǒng),就只關(guān)心對象的體積和位置,不關(guān)心對象的名字,連接狀態(tài),音效、敵對關(guān)系等。它也不一定關(guān)心游戲世界中的所有對象,比如關(guān)心那些不參與碰撞的裝飾物。所以對每個子系統(tǒng)來說,篩選出系統(tǒng)關(guān)心的對象子集以及只給它展示它所關(guān)心的數(shù)據(jù)就是框架的責任了。

在ECS框架中,把每個可能單獨使用的對象屬性歸納為一個個Component,比如對象的名字就是一個Component,對象的位置狀態(tài)是另一個Component。每個Entity是由多個Component組合而成,共享一個生命期;而Component之間可以組合在一起作為System篩選的標準。我們在開發(fā)的時候,可以定義一個System關(guān)心某一個固定Component的組合;那么框架就會把游戲世界中滿足有這個組合的Entity都篩選出來供這個System遍歷,如果一個Entity只具備這組Component中的一部分,就不會進入這個篩選集合,也就不被這個System所關(guān)心了。

在演講中,作者談到了一個根據(jù)輸入狀態(tài)來決定是不是要把長期不產(chǎn)生輸入的對象踢下線的例子,就是要對象同時具備連接組件、輸入組件等,然后這個AFK處理系統(tǒng)遍歷所有符合要求的對象,根據(jù)最近輸入事件產(chǎn)生的時間,把長期沒有輸入事件的對象通知下線;他特別說到,AI控制的機器人,由于沒有連接組件,雖然具備狀態(tài)組件,但不滿足AFK系統(tǒng)要求的完整組件組的要求,就根本不會遍歷到,也就不用在其上面浪費計算資源了。我認為這是ECS相對傳統(tǒng)對象Update模型的一點優(yōu)勢;用傳統(tǒng)方法的話,很可能需要寫一個空的Update函數(shù)。

游戲的業(yè)務(wù)循環(huán)就是在調(diào)用很多不同的系統(tǒng),每個系統(tǒng)自己遍歷自己感興趣的對象,只有預(yù)定義的組件部分可以被子系統(tǒng)感知到,這樣每個系統(tǒng)就能具備很強的內(nèi)聚性。注意、這和傳統(tǒng)的面向?qū)ο蠡蚴茿ctor模型是截然不同的。OO或Actor強調(diào)的是對象自身處理自身的業(yè)務(wù),然后框架去管理對象的集合,負責用消息驅(qū)動它們。而在ECS中,每個系統(tǒng)關(guān)注的是不同的對象集合,它處理的對象中有共性的切片。這是很符合守望先鋒這種MOBA類游戲的。這類游戲關(guān)注的是對象間的關(guān)系,比如A攻擊了B對B造成了傷害,這件事情是在A和B之間發(fā)生的,在傳統(tǒng)模型中,你會糾結(jié)于傷害計算到底在A對象的方法中完成還是在B的方法中完成。而在ECS中不需要糾結(jié),因為它可以在傷害計算這個System中完成,這個System關(guān)注的是所有對象中,和傷害的產(chǎn)生有關(guān)的那一小部分數(shù)據(jù)的集合。

ECS的設(shè)計就是為了管理復(fù)雜度,它提供的指導(dǎo)方案就是Component是純數(shù)據(jù)組合,沒有任何操作這個數(shù)據(jù)的方法;而System是純方法組合,它自己沒有內(nèi)部狀態(tài)。它要么做成無副作用的純函數(shù),根據(jù)它所能見到的對象Component組合計算出某種結(jié)果;要么用來更新特定Component的狀態(tài)。System之間也不需要相互調(diào)用(減少耦合),是由游戲世界(外部框架)來驅(qū)動若干System的。如果滿足了這些前提條件,每個System都可以獨立開發(fā),它只需要遍歷給框架提供給它的組件集合,做出正確的處理,更新組件狀態(tài)就夠了。編寫Gameplay的人更像是在用膠水粘合這些System,他只要清楚每個System到底做了什么,操作本身對哪些Component造成了影響,正確的書寫System的更新次序就可以了。一個System對大多數(shù)Component是只讀的,只對少量Component是會改寫的,這個可以預(yù)先定義清楚,有了這個知識,一是容易管理復(fù)雜度,二是給并行處理留下了優(yōu)化空間。

在演講中談到了開發(fā)團隊對ECS的設(shè)計認知也是逐步演進的。

比如在一開始,他們認為Component就是大量有某種同類Entity屬性的集合的篩選器。ECS框架輔助這個篩選過程,每個System模塊都用for each的方式迭代相關(guān)的Entity中對象的組件。之后他們發(fā)現(xiàn),其實對于每個游戲?qū)ο蠹象w來說,一類Component可以也應(yīng)該只有一個。比如存放玩家鍵盤輸入的Component,就沒有多個。很多System都需要去讀這個唯一的Component內(nèi)的狀態(tài)(哪些按鈕被按下了),可以安排一個System來更新這個Component。原文把這種Component成為Singleton Component,我認為這個東西和一開始ECS想解決的問題還是有一些差別的:不同種類的Entity分別擁有同類的屬性組,框架負責管理同類集合。我們的確還是可以創(chuàng)建一個叫做玩家鍵盤的Entity加到游戲世界中,這個Entity是由鍵盤組件構(gòu)成。但是我們完全不必迭代玩家鍵盤這個Entity集合,因為它肯定只有一個,直接把這個對象放在游戲世界中即可。但把它放在System中就不是一個好設(shè)計了。因為它破壞了System無狀態(tài)的設(shè)計原則,而且也不支持多個游戲世界:在原文中舉了個例子,實際游戲和游戲回放就是兩個不同的游戲世界,不同的游戲世界意味著不同的業(yè)務(wù)流程的組合,需要用不同的方式粘合已經(jīng)開發(fā)好的System。把游戲鍵盤狀態(tài)這種狀態(tài)內(nèi)置在特定的System中就是不合適的了。從這個角度來說ECS的本質(zhì)還是數(shù)據(jù)C和操作S分離。而操作S并不局限于對同類組件集合的管理,也可是是針對單個組件。作者自己也說,最終有40%的組件就是單件。

單件本身其實就和傳統(tǒng)面向?qū)ο竽P筒畈欢嗔?。但是?shù)據(jù)和方法分離還是很有意義。我們在用面向?qū)ο竽J阶鲩_發(fā)的時候也會碰到一個對象有幾個不同的方法,某些方法關(guān)注這部分狀態(tài)、另一些方法關(guān)注另一部分狀態(tài),還有一些方法關(guān)注前面幾組狀態(tài)的集合。這里的方法就是ECS中的系統(tǒng)、狀態(tài)就是組件。將數(shù)據(jù)和方法分離可以將不同的方法解耦。如果用傳統(tǒng)的C++的面向?qū)ο竽J?,很可能需要用多繼承、組合轉(zhuǎn)發(fā)等等復(fù)雜的語法手段。

演講后面還提到了一些ECS模式下處理一些復(fù)雜問題的常見手法。

Component沒有方法,而System則沒有狀態(tài),只是對定義好的Component狀態(tài)的加工過程。而許多System中很可能會處理同一類問題,涉及的Component類型是相同的。如果這個有共性的問題只涉及一個Entity,那么直觀的方法是設(shè)計一個System,迭代,逐個把結(jié)果計算出來,存為Component的狀態(tài),別的System可以在后續(xù)把這個結(jié)果作為一個狀態(tài)讀出來就可以了。

但如果這個行為涉及多個Entity,比如在不同的System中,都需要查詢兩個Entity的敵對關(guān)系。我們不可能用一個System計算出所有Entity間的敵對關(guān)系,這樣必然產(chǎn)生了大量不必要的計算;又或者這個行為并不想額外修改Component的狀態(tài),希望對它保持無副作用,比如我想持續(xù)模擬一個對象隨時間流逝的位置變化,就不能用一個System計算好,再從另一個System讀出來。

這樣,就引入了Utility函數(shù)的概念,來做上面這種類型的操作,再把Utility函數(shù)共享給不同的System調(diào)用。為了降低系統(tǒng)復(fù)雜度,就要求要么這種函數(shù)是無副作用的,隨便怎么調(diào)用都沒問題,比如上面查詢敵對關(guān)系的例子;要么就限制調(diào)用這種函數(shù)的地方,僅在很少的地方調(diào)用,由調(diào)用者小心的保證副作用的影響,比如上面那個持續(xù)位置變化的過程。

如果產(chǎn)生狀態(tài)改變這種副作用的行為必須存在時,又在很多System中都會觸發(fā),那么為了減少調(diào)用的地方,就需要把真正產(chǎn)生副作用的點集中在一處了。這個技巧就是推遲行為的發(fā)生時機。就是把行為發(fā)生時需要的狀態(tài)保存起來,放在隊列里,由一個單獨的System在獨立的環(huán)節(jié)集中處理它們。

例如不同的射擊行為都可能創(chuàng)建出新的對象、破壞場景、影響已有對象的狀態(tài)。在同一面墻上留下不同的彈孔,不需要堆疊在一起,而只需要保留最后一個,刪除前面的。我們可以把讓不同的System觸發(fā)這些對象創(chuàng)建、刪除的行為,但并不真正去做。集中在一起推遲到當前幀的末尾或下一幀的開頭來做。這樣就盡量保證了多數(shù)System工作的時候,對大多數(shù)組件來說是無副作用的,而把嚴重副作用的行為集中在單點小心處理。

ECS要解決的最復(fù)雜,最核心的問題,或許還是網(wǎng)絡(luò)同步。我認為這也是設(shè)計一個狀態(tài)和行為嚴格分離的框架的主要動機。因為一個好的網(wǎng)絡(luò)同步系統(tǒng)必須實現(xiàn)預(yù)測、有預(yù)測就有預(yù)測失敗的情況,發(fā)生后要解決沖突,回滾狀態(tài)是必須支持的。而狀態(tài)回滾還包括了只回滾部分狀態(tài),而不能簡單回滾整個世界。

我在去年其實在本blog中談過這個問題。我的觀點是,狀態(tài)的單獨保存是非常重要的。在ECS模型中,C是純數(shù)據(jù),所以非常方便做快照和回滾。Entity的組件分離,也適合做關(guān)鍵狀態(tài)的記錄。去年和一個同事一起做了一個射擊類的MOBA demo,最終的實現(xiàn)方案就是把游戲?qū)ο蟮奈恢茫ㄒ苿樱顟B(tài),和射擊狀態(tài)專門抽出來實現(xiàn)預(yù)測同步,效果非常不錯。

這個演講其實并沒有談及預(yù)測和同步的具體技術(shù),而是談ECS怎么幫助降低利用這些技術(shù)的實現(xiàn)復(fù)雜度。同時也提及了一些有趣的細節(jié)。

比如說,ECS規(guī)定每個需要根據(jù)輸入表現(xiàn)的System都提供了一個UpdateFixed函數(shù)。守望先鋒的同步邏輯是基于60fps的,所以這個UpdateFixed函數(shù)會每16ms調(diào)用一次,專門用于計算這個邏輯幀的狀態(tài)。服務(wù)器會根據(jù)玩家延遲,稍微推遲一點時間,比客戶端晚一些調(diào)用UpdateFixed。在我去年談同步的blog中也說過,玩家其實不關(guān)心各個客戶端和服務(wù)器是不是時刻上絕對一致(絕對一致是不可能做到的),而關(guān)心的是,不同客戶端和服務(wù)器是不是展現(xiàn)了相同的過程。就像直播電影,不同的位置早點播放和晚點播放,大家看到的內(nèi)容是一致的就夠了,是不是同時在觀看并不重要。

但是,游戲和電影不一樣的地方是,玩家自己的操作影響了電影的情節(jié)。我們需要在服務(wù)器仲裁玩家的輸入對世界的影響。玩家需要告知服務(wù)器的是,我這個操作是在電影開場的幾分幾秒下達的,服務(wù)器按這個時刻,把操作插入到世界的進程中。如果客戶端等待服務(wù)器回傳操作結(jié)果那就實在是太卡了,所以客戶端要在操作下達后自己模擬后果。如果操作不被打斷,其實客戶端模擬的結(jié)果和服務(wù)器仲裁后的結(jié)果是一樣的,這樣服務(wù)器在回傳后告之客戶端過去某個時間點的對象的狀態(tài),其實和當初客戶端模擬的其實就是一致的,這種情況下,客戶端就開開心心繼續(xù)往前跑就好了。

只有在預(yù)測操作時,比如玩家一直在向前跑,但是服務(wù)器那里感知到另一個玩家對他釋放了一個冰凍,將他頂在原地。這樣,服務(wù)器回傳給玩家的位置數(shù)據(jù):他在某時刻停留在某地就和當初他自己預(yù)測的那個時刻的位置不同。產(chǎn)生這種預(yù)測失敗后,客戶端就需要自己調(diào)節(jié)。有ECS的幫助,狀態(tài)回滾到發(fā)生分歧的版本,考慮到服務(wù)器回傳的結(jié)果和新了解到的世界變化,重新將之后一段時間的操作重新作用到那一刻的狀態(tài)上,做起來就相對簡單了。

對于服務(wù)器來說,它默認客戶端會持續(xù)不斷的以固定周期向它推送新的操作。正如前面所說,服務(wù)器的時刻是有意比客戶端延后的,這樣,它并非立刻處理客戶端來的輸入,而是把輸入先放在一個緩沖區(qū)里,然后按和客戶端固定的周期(60fps)從緩沖區(qū)里取。由于有這個小的緩沖區(qū)的存在,輕微的網(wǎng)絡(luò)波動(每個網(wǎng)絡(luò)包送達的路程時間不完全一致)是完全沒有影響的。但如果網(wǎng)絡(luò)不穩(wěn)定,就會出現(xiàn)到時間了客戶端的操作還沒有送到。這個時候,服務(wù)器也會嘗試預(yù)測一下客戶端發(fā)生了什么。等真的操作包到達后,比對一下和自己的預(yù)測值有什么不同,基于過去那個產(chǎn)生分歧的預(yù)測產(chǎn)生的狀態(tài)和實際上傳的操作計算出下一個狀態(tài)。

同時,這個時候服務(wù)器會意識到網(wǎng)絡(luò)狀態(tài)不好,它主動通知客戶端說,網(wǎng)絡(luò)不太對勁,這個時候的大家遵循的協(xié)議就比較有趣了。那就是客戶端得到這個消息就開始做時間壓縮,用更高的頻率來跑游戲,從60fps提高到65fps,玩家會在感受到輕微的加速,結(jié)果就是客戶端用更高的頻率產(chǎn)生新的輸入:從16 ms一次變成了15.2 ms一次。也就是說,短時間內(nèi),客戶端的時刻更加領(lǐng)先服務(wù)器了,且越領(lǐng)先越多。這樣,服務(wù)器的預(yù)讀隊列就能更多的接收到未來將發(fā)生的操作,遇到到點卻不知道客戶端輸入的可能性就變少了。但是總流量并沒有增加,因為假設(shè)一局游戲由一萬個tick組成,無論客戶端怎么壓縮時間,提前時刻,總的數(shù)據(jù)還是一萬個tick產(chǎn)生的操作,并沒有變化。

一旦度過了網(wǎng)絡(luò)不穩(wěn)定期,服務(wù)器會通知客戶端已經(jīng)正常了,這個時候客戶端知道自己壓縮時間導(dǎo)致的領(lǐng)先時長,對應(yīng)的膨脹放慢時間(降低向服務(wù)器發(fā)送操作的頻率)讓狀態(tài)回到原點即可。

btw,守望先鋒是基于UDP通訊的,從演講介紹看,對于UDP可能丟包的這個問題,他們處理的簡單粗暴:客戶端每次都將沒有經(jīng)過服務(wù)器確認的包打包在一起發(fā)送。由于每個邏輯幀的操作很少,打包在一起也不會超過MTU限制。

ECS在這個過程中真正發(fā)生威力的地方是在預(yù)測錯誤后糾正錯誤的階段。一旦需要糾正過去發(fā)生的錯誤,就需要回滾、重新執(zhí)行指令。移動、射擊這些都屬于常規(guī)的設(shè)定,比較容易做回滾重新執(zhí)行;技能本身是基于暴雪開發(fā)的Statescript的,通過它來達到同樣的效果。ECS的威力在于,把這些元素用Component分離了,可以單獨處理。

比如說射擊命中判定,就是一個單獨的系統(tǒng),它基于被判定對象都有一個叫做ModifyHealthQueue的組件。這個組件里記錄的是Entity身上收到的所有傷害和治療效果。這個組件可以用于Entity的篩選器,沒有這個組件的對象不會受到傷害,也就不需要參與命中判定。真正影響命中判定的是MovementState組件,它也參與了命中判定這個系統(tǒng)的篩選,并真正參與了運算。命中判定在查詢了敵對關(guān)系后從MovementState中獲取應(yīng)該比對的對象的位置,來預(yù)測它是否被命中(可能需要播放對應(yīng)的動畫)。但是傷害計算,也就是ModifyHealthQueue里的數(shù)據(jù)是只能在服務(wù)器填寫并推送給客戶端的。

MovementState會因為需要糾正錯誤預(yù)測而被回退,同時還有一些非MovementState的狀態(tài)也會回退,比如門的狀態(tài)、平臺的狀態(tài)等等。這個回退是Utility函數(shù)的行為,它可能會影響受擊的表現(xiàn),而受傷則是另一種固定行為(服務(wù)器確定的推送)的后果。他們發(fā)生在Entity的不同組件切片上,就可以正交分離。

射擊預(yù)測和糾正可以利用對象的活動區(qū)域來減少判定計算量。如果能總是計算保持當前對象在過去一段時間的最大移動范圍(即過去一段時間的包圍盒的并集),那么當需要做一個之前發(fā)生的射擊命中判定時,就只需要把射擊彈道和當前所有對象的檢測區(qū)域比較,只有相交才做進一步檢測:回退相關(guān)對象到射擊發(fā)生的時刻,做嚴格的命中校驗。如果當初預(yù)測的命中結(jié)果和現(xiàn)在核驗的一致就無所謂了,不需要修正結(jié)果(如果命中了,具體打中在哪不重要;如果未命中,也不管射到哪里去了)。

如果ping值很高,客戶端做命中預(yù)測往往是沒有什么意義的,徒增計算量。所以在Ping超過220ms后,客戶端就不再提前預(yù)測命中事件,直接等服務(wù)器回傳。

ECS框架在這件事上可以做到只去回滾和重算相關(guān)的Component,一個System知道哪些Entity才是它真正關(guān)心的,該怎么回退它所關(guān)心的東西。這樣開發(fā)的復(fù)雜度就減少了。游戲本身是復(fù)雜的,但是和網(wǎng)絡(luò)同步相關(guān)的影響到游戲業(yè)務(wù)的System卻很少,而且參與的Component幾乎都是只讀的。這樣我們就盡可能的把這個復(fù)雜的問題和引擎部分解耦。

ECS是個不錯的框架,但是需要遵循一定的規(guī)范才能起到他應(yīng)有的效果:減少大量系統(tǒng)間的耦合度。但并非所有的問題都適合遵循ECS的規(guī)范來開發(fā),尤其是一些舊有的模塊,很難做到把數(shù)據(jù)結(jié)構(gòu)按Component得規(guī)范暴露出來,并把狀態(tài)改變的方法集成到獨立的System中去。這個時候就應(yīng)該做一些封裝的工作。比如說有些系統(tǒng)原本就利用了多線程模型作并行優(yōu)化,所以我們需要把這些已經(jīng)做好的工作隔離在ECS框架之外,僅僅暴露一些接口和ECS框架對接。

銳亞教育

銳亞教育,游戲開發(fā)論壇|游戲制作人|游戲策劃|游戲開發(fā)|獨立游戲|游戲產(chǎn)業(yè)|游戲研發(fā)|游戲運營| unity|unity3d|unity3d官網(wǎng)|unity3d 教程|金融帝國3|8k8k8k|mcafee8.5i|游戲蠻牛|蠻牛 unity|蠻牛