幀同步優化難點及解決方案

這是侑虎科技第418篇文章,感謝作者Gordon供稿。歡迎轉發分享,未經作者授權請勿轉載。如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ羣465082844)

關於作者:https://zhuanlan.zhihu.com/p/38468615

作者也是U Sparkle活動參與者,UWA歡迎更多開發朋友加入U Sparkle開發者計劃,這個舞臺有你更精彩!


幀同步這部分比較複雜,細枝末節有很多優化點,也有一些不同的優化方向,根據不同項目類型、對操作手感的要求、聯機玩家的個數等,會有不同的難點和痛點。不同的優化方向,優化手法的差異,可能導致一些爭論。並且,幀同步,本身也有很多變種,以應對不同的需求。所以,本文一切都是基於作者的項目類型(ACT)來做的方案和優化,並不一定適合其它也需要幀同步的遊戲,故在此提前說一下,以免引起一些不必要的誤解。

幀同步的幾個難點

幀同步的基礎原理,以及和狀態同步的區別,已經有很多文章介紹,我就不再贅述,大家可以自行google。以下只說幾個難點。

  • 保證客戶端獨自計算的正確,即一致性

幀同步的基礎,是不同的客戶端,基於相同的操作指令順序,各自執行邏輯,能得到相同的效果。就如大家所知道的,在Unity引擎中,不同的調用順序,時序,浮點數計算的偏差,容器的排序不確定性,Coroutine內寫邏輯帶來的不確定性,物理浮點數,隨機數值帶來的不確定性等等。

有些比較好解決,比如隨機數值,只需要做隨機種子即可。

有些需要注意代碼規範,比如在幀同步的戰鬥中,邏輯部分不使用Coroutine,不依賴類似Dictionary等不確定順序的容器的循環等。

還有最基礎的,要通過一個統一的邏輯Tick入口,來更新整個戰鬥邏輯,而不是每個邏輯自己去Update。保證每次Tick都從上到下,每次執行的順序一致。

物理方面,因爲我們戰鬥邏輯不需要物理,碰撞都是自己做的碰撞邏輯,所以,跳過不說,這塊可以參考別的文章。

最後,浮點數計算無法保證一致性,我們需要轉換爲定點數。關於定點數的實現,比較簡單的方式是,在原來浮點數的基礎上乘1000或10000,對應地方除以1000或10000,這種做法最爲簡單,再輔以三角函數查表,能解決一些問題,減少計算不一致的概率。但是,這種做法是治標不治本的方式,存在一些隱患(舉個例子,例如一個int和一個float做乘法,如果原數值就要*1000,那最後算出來的數值,可能會非常大,有越界的風險。)

最佳的解決辦法是:使用實現更加精確和嚴謹,並經過驗證的定點數數學庫,在C#上,有一個定點數的實現,Photon網絡的早期版本,Truesync有一個很不錯的定點數實現。

定點數的實現

其中FP,就可以完全代替Float,我們只需要將我們自己的邏輯部分,Float等改造爲FP,就可以輕鬆解決。並且,能夠很好的和我們Protobuf的序列化方式集成(注意代碼中的Attribute,如下圖),保證我們的配置文件,也是定點數的。

TSVector對應Vector3,只要基於FP,可以自己擴展自己的數據結構。(當然,如果用到了複雜的插件,並且不開源,那麼對於定點數的改造,就會困難很多)

三角函數通過查表方式實現,保證了定點數的準確

我個人認爲,這一套的實現,是優於簡單的乘10000,除10000的方式。帶來的壞處,可能就是計算性能略差一點點,但是我們大量測試下來,對計算性能的影響很小,應該是能勝任絕大部分項目的需求。

對於計算的不確定性,我們也有一些小的隱患,就是我們用到了Physics.Raycast來檢測地面和圍牆,讓人物可以上下坡,走樓梯等高低不平的路,也可以有形狀不規則的牆。這裏會獲得一個浮點數的位置,可能會導致不確定性,這裏,我們用了數值截斷等方式,儘量規避,經過反覆測試,沒有出現過不一致。但是這種方式,畢竟在邏輯上,存在隱患,更好的方式,是實現一套基於定點數的raycast機制,我們人力有限,就沒時間精力去做了。這塊有篇文章講得更細緻一些,大家可以參看:

幀同步:浮點精度測試

https://zhuanlan.zhihu.com/p/30422277

  • 幀同步網絡協議的實現

在處理好了基礎的計算一致性問題後,我們就要考慮網絡如何通信。這裏,我不談p2p方式了,我們以下談的,都是多client,一個server的模式,server負責統一tick,並轉發client的指令,通知其他client,可以參看文章:

網遊流暢基礎:幀同步遊戲開發

http://www.10tiao.com/html/255/201609/2650586281/4.html

首先,是網絡協議的選擇。TCP和UDP的選擇,我就不多說了,幀同步肯定要基於UDP才能保證更低的延遲。在UDP的選擇上,我看網上有些文章,容易導入一個誤區,即,我們是要用可靠傳輸的UDP,還是冗餘信息的UDP。

基於可靠傳輸的UDP,是指在UDP上加一層封裝,自己去實現丟包處理,消息序列,重傳等類似TCP的消息處理方式,保證上層邏輯在處理數據包的時候,不需要考慮包的順序,丟包等。類似的實現有Enet,KCP等。

冗餘信息的UDP,是指需要上層邏輯自己處理丟包,亂序,重傳等問題,底層直接用原始的UDP,或者用類似Enet的Unsequenced模式。常見的處理方式,就是兩端的消息裏面,帶有確認幀信息,比如客戶端(C)通知服務器(S)第100幀的數據,S收到後通知C,已收到C的第100幀,如果C一直沒收到S的通知(丟包,亂序等原因),就會繼續發送第100幀的數據給S,直到收到S的確認信息。

有些文章介紹的時候,沒有明確這兩者的區別,但是這兩種方式,區別是巨大的。可靠傳輸的UDP,在幀同步中,個人認爲是不合適的,因爲他爲了保證包的順序和處理丟包重傳等,在網絡不佳的情況下,Delay很大,將導致收發包處理都會變成類似TCP的效果,只是比TCP會好一些。必須要用冗餘信息的UDP的方式,才能獲得好的效果。並且實現並不複雜,只要和服務器商議好確認幀和如何重傳即可,自己實現,有很大的優化空間。例如,我們的協議定義類似如下:

雙方都要通知對方,已經接受哪一幀的通知了,並通過cmd list重發沒有收到的指令

這裏簡單說一下,對於這種收發頻繁的消息,如果使用Protobuf,會造成每個邏輯幀的GC,這是非常不好的,解決方案,要麼對Protobuf做無GC改造,要麼就自己實現一個簡單的byte[]讀寫。無GC改造工程太大,感覺無必要,我們只是在戰鬥的幾個頻繁發送的消息,需要自己處理一下byte[]讀寫即可。

在這部分需要補充一下,KCP作者韋易笑提到KCP+FEC的模式,可以比冗餘方式,有更好的效果,我之前並沒有仔細研究過這個模式,不過可以推薦大家看一下。

因爲我們項目早期,服務器定下了使用Enet,我評估了一下,反正使用冗餘包的方式,所以沒有糾結Enet或KCP,後續其實想改成KCP,服務器不想再動,也就放下了。

Enet麻煩的地方是,Enet的ipv6版本,是一個不成熟的Pull Request,Enet作者沒有Merge(並且存在好幾個ipv6的Pull Request),我不確定穩定性,還好看了下Commit,加上測試下來,沒有太大問題。KCP我沒有評估過ipv6的問題,不過Github上有C#版本,改一下ipv6支持應該很簡單。

  • 邏輯和顯示的分離

這部分在很多講幀同步的文章中都提過了。配置的數據和顯示要分離,在戰鬥中,戰鬥的邏輯,也要和顯示做到分離。

例如,我們動作切換的邏輯,是基於自己抽象的邏輯幀,而不是基於Animator中一個Clip的播放。比如一個攻擊動作,當第10幀的時候,開始出現攻擊框,並開始檢測和敵人受擊框的碰撞,這個時候的第10幀,必須是獨立的邏輯,不能依賴於Animator播放的時間,或者AnimatorStateInfo的NormalizedTime等。甚至,當我們不加載角色的模型,一樣可以跑戰鬥的邏輯。如果抽離得好,還可以放到服務器跑,做爲戰鬥的驗證程序,王者榮耀就是這樣做的。

  • 聯機如何做到流暢戰鬥

前面所有的準備,最終的目的,都是爲了戰鬥的流暢。特別是我們這種ACT遊戲,或者格鬥類遊戲,對按鍵以後操作反饋的即時性,要求非常高,一點點延遲,都會影響玩家的手感,導致玩家的連招操作打斷,非常影響體驗。我們對延遲的敏感性,甚至比MOBA類遊戲還要高,我們要做到好的操作手感,還要聯機戰鬥(PVP,組隊PVE),都需要把幀同步做到極致,不能因爲延遲卡住或者操作反饋出現變化。

因爲這個原因,我們不能用Lockstep的方式,Lockstep更適合網絡環境良好的內網,或者對操作延遲不敏感的類型(例如我聽過還有項目用來做卡牌類的幀同步)。

我們也不能用緩存服務器確認操作的方式,也就是一些遊戲做的指令Buffer。具體描述,王者榮耀的分析文章,講得很具體了。這也是他們說的模式,這個模式能解決一些小的網絡波動,對一些操作反饋不需要太高的遊戲,例如有些遊戲攻擊前會有一個比較長的前搖動作,這類遊戲,用這種方式,應該就能解決大部分問題。但是這種方式還是存在隱患,即使通過策略能很好地動態調整Buffer,也還是難以解決高延遲下的卡頓和不流暢。王者榮耀優化得很好,他們說能讓Buffer長度爲0,文章只提到通過平滑插值和邏輯表現分離來優化,更細節的沒有提到,我不確定他們是否只是基於這個方式來優化的。目前也沒有看到更具體的分析。

指令Buffer的方式,也不能滿足我們的需求,或者說,我沒有找到基於此方式,能優化到王者榮耀的效果的辦法。我也測試過其他MOBA和ACT,ARPG類遊戲的聯機,在高延遲,網絡波動情況下,沒有比王者表現更好的了。

最後,在仔細研究了我們的需求後,找到一篇指導性的文章,非常適合我們:

Understanding Fighting Game Networking

http://mauve.mizuumi.net/2012/07/05/understanding-fighting-game-networking/

這篇文章非常詳細地介紹了各種方式,最終回滾邏輯(Rollback)是終極的解決方案,國內也有文章提到過,即:

Skywind Inside  再談網遊同步技術

http://www.skywind.me/blog/archives/1343#more-1343

文章裏面提到的Time Warp方式,我理解回滾邏輯,和Time Warp是一個概念。

  • 遊戲邏輯的回滾

回滾邏輯,就是我們解決問題的方案。可以這樣理解,客戶端的時間,領先服務器,客戶端不需要服務器確認幀返回才執行指令,而是玩家輸入,立刻執行(其他玩家的輸入,按照其最近一個輸入做預測,或者其他更優化的預測方案),然後將指令發送給服務器,服務器收到後給客戶端確認,客戶端收到確認後,如果服務確認的操作,和之前執行的一樣(自己和其他玩家預測的操作),將不做任何改變,如果不一樣(預測錯誤),就會將遊戲整體邏輯回滾到最後一次服務器確認的正確幀,然後再追上當前客戶端的幀。

此處邏輯較爲複雜,我嘗試舉個例子說明下。

當前客戶端(A,B)執行到100幀,服務器執行到97幀。在100幀的時候,A執行了移動,B執行了攻擊,A和B都通知服務器:我已經執行到100幀,我的操作是移動(A),攻擊(B)。服務器在自己的98幀或99幀收到了A,B的消息,存在對應幀的操作數據中,等服務器執行到100幀的時候(或提前),將這個數據廣播給AB。

然後A和B立刻開始執行100幀,A執行移動,預測B不執行操作。而B執行攻擊,預測A執行攻擊(可能A的99幀也是攻擊),A和B各自預測對方的操作。

在A和B執行完100幀後,他們會各自保存100幀的狀態快照,以及100幀各自的操作(包括預測的操作),以備萬一預測錯誤,做邏輯回滾。

執行幾幀後,A和B來到了103幀,服務器到了100幀,他開始廣播數據給AB,在一定延遲後,AB收到了服務器確認的100幀的數據,這時候,AB可能已經執行到104了。A和B各自去核對服務器的數據和自己預測的數據是否相同。例如A覈對後,100幀的操作,和自己預測的一樣,A不做任何處理,繼續往前。而B覈對後,發現在100幀,B對A的預測,和服務器確認的A的操作,是不一樣的(B預測的是攻擊,而實際A的操作是移動),B就回滾到上一個確認一樣的幀,即99幀,然後根據確認的100幀操作去執行100幀,然後快速執行101~103的幀邏輯,之後繼續執行104幀,其中(101~104)還是預測的邏輯幀。

因爲客戶端對當前操作的立刻執行,這個操作手感,是完全和PVE(不聯網狀態)是一樣的,不存在任何Delay。所以,能做到絕佳的操作手感。當預測不一樣的時候,做邏輯回滾,快速追回當前操作。

這樣,對於網絡好的玩家,和網絡不好的玩家,都不會互相影響,不會像Lockstep一樣,網絡好的玩家,會被網絡不好的玩家Lock住。也不會被網絡延遲Lock住,客戶端可以一直往前預測。

對於網絡好的玩家(A),可以動態調整(根據動態的Latency),讓客戶端領先服務器少一些,儘量減少預測量,就會盡量減少回滾,例如網絡好的,可能客戶端只領先2~3幀。

對於網絡不好的玩家(B),動態調整,領先服務器多一些,根據Latency調整,例如領先5幀。

那麼,A可能預測錯的情況,只有2~3幀,而網絡不好的B,可能預測錯誤的幀有5幀。通過優化的預測技術,和消息通知的優化,可以進一步減少A和B的預測錯誤率。對於A而言,戰鬥是順暢的,手感很好,少數情況的回滾,優化好了,並不會帶來卡頓和延遲感。

重點優化的是B,即網絡不好的玩家,他的操作體驗。因爲客戶端不等待服務器確認,就執行操作,所以B的操作手感,和A是一致的,區別只在於,B因爲延遲,預測了比較多的幀,可能導致預測錯,回滾會多一些。比如按照B的預測,應該在100幀擊中A,但是因爲預測錯誤A的操作,回滾重新執行後,B可能在100幀不會擊中A。這對於B來說,通過插值和一些平滑方式,B的感受是不會有太大區別的,因爲B看自己,操作自己都是及時反饋的,他感覺自己是平滑的。

這種方式,保證了網絡不好的B的操作手感,和A一致。回滾導致的一些輕微的抖動,都是B看A的抖動,通過優化(插值,平滑等),進一步減少這些後,B的感受是很好的。我們測試在200~300毫秒隨機延遲的情況下,B的操作手感良好。

這裏,客戶端提前服務器的方式,並且在延遲增大的情況下,客戶端將加速,和

守望先鋒的處理方式是一樣的。當然,他們肯定比我做得好很多:

https://mp.weixin.qq.com/s/cOGn8-rHWLIxdDz-R3pXDg

希望我已經大致講清楚了這個邏輯,大家參看幾篇鏈接的文章,能體會更深。

這裏,我要強調的一點是,我們這裏的預測執行,是真實邏輯的預測,和很多介紹幀同步文章提到的預測是不同的。有些文章介紹的預測執行,只是View層面的預測,例如前搖動作和位移,但是邏輯是不會提前執行的,還是要等服務器的返回。這兩種預測執行(View的預測執行,和真實邏輯的預測執行)是完全不是一個概念的,這裏需要仔細地區分。

這裏有很多的可以優化的點,我就不一一介紹了,以後可能零散地再談。

  • 遊戲邏輯的快照(snapshot)

我們的邏輯之所以能回滾,都是基於對每一幀狀態可以處理快照,存儲下每一幀的狀態,並可以回滾到任何一幀的狀態。

Understanding Fighting Game Networking文章:

http://mauve.mizuumi.net/2012/07/05/understanding-fighting-game-networking/

守望先鋒網絡文章:

https://mp.weixin.qq.com/s/cOGn8-rHWLIxdDz-R3pXDg

以上兩篇文章都一筆帶過了快照的說明。他們說的快照,可能略有不同,但是思路,都是能保存下每一幀的狀態。如果去處理快照(Understanding那篇文章做的是模擬器遊戲,可以方便地以內存快照的方式來做),是一個難點。

這也是我前面文章,提到ECS在這個方式下的應用:

https://zhuanlan.zhihu.com/p/38280972

雲風的解釋:

雲風博客截圖,地址https://blog.codingnow.com/2017/06/overwatch_ecs.html

ECS是一個好的處理方式,並且我找到一篇文章,也這樣做了(我看過他開源的demo,做得還不夠好,應該還是demo階段,不太像是一個成型的項目)。

https://www.kisence.com/2017/11/12/guan-yu-zheng-tong-bu-de-xie-xin-de/

這篇文章的思路是很清晰的,並且也點到了一些實實在在的痛點,解決思路也基本是正確的,可以參看。

這塊我做得比較早了,當時守望先鋒的文章還沒出,我的戰鬥也沒有基於ECS,所以,在處理快照上,只有自己理順邏輯來做了。

我的思路是,通過一個回滾接口,需要數據回滾的部分,實現接口,各自處理自己的保存快照和回滾。就像我們序列化一個複雜的配置,每個配置各自序列化自己的部分,最終合併成一個序列化好的文件。

首先,定義接口,和快照數據的Reader和Writer

然後,就是每個模塊,自己去處理自己的TakeSnapshot和Rollback,例如:

簡單的數值回滾

複製的列表回滾和調用子模塊回滾

思路理順以後,就可以很方便地處理了,注意Write和Read的順序,注意處理好List,就解決了大部分問題。當然,在實現邏輯的過程中,時刻要注意,一個模塊如何回滾(例如獲取隨機數也需要回滾)。

有一個更簡單的方式,就是給屬性打Attribute,然後寫通用的方法。例如,我早期的實現方案:

給屬性打標籤

根據標籤,通用的讀寫方法,通過反射來讀寫,就不需要每個模塊自己去實現自己的方法了:

部分代碼

這種方法,能很好地解決大部分問題,甚至前面提到的Truesync,也是用的這種方式來做。

但是這種方法有個難以迴避的問題,就是GC,因爲基於反射,當我們調用field的GetValue和SetValue的時候,GC難以避免。並且,因爲全自動,不方便處理一些特殊邏輯,調試優化也不方便,最後改成了現有的方式,雖然看起來笨重一些,但是可控性更強,我後續做的很多優化,都方便很多。

關於快照,也有很多可以優化的點,無論是GC內存上的,還是運行效率上的,都需要優化好,否則,可能帶來性能問題。這塊優化,有空另闢文章再細談吧。

當我們有了快照,就可以支持回滾,甚至跳轉。例如我們要看戰鬥錄像,如果沒有快照,我們要跳到1000幀,就需要從第一幀,根據保存的操作指令,一直快速執行到1000幀,而有了快照,可以直接跳到1000幀,不需要執行中間的過程,如果需要在不同的幀之間切換,只需要跳轉即可,這將帶來巨大的幫助。

  • 自動測試

由於幀同步需要測試一致性的問題,對我們來說,回滾也是需要大量測試的問題。自動測試是必須要做的一步,這塊沒有什麼特別的點,主要就是保存好操作,快照,log,然後對不同客戶端的數據做比對,找到不同的地方,查錯改正。

我們現在做到,一步操作,自動循環戰鬥,將每一盤戰鬥數據上傳內網log服務器。

當有很多盤戰鬥的數據後,通過工具自動解析比對數據,找到不同步的點。也是還可以優化得更好,只是現在感覺已經夠用了。經過大量的內部自動測試,目前戰鬥的一致性,是很好的。

 

總結

我們現在的幀同步方案,總結下來,就是預測,快照,回滾。當把這些有機地結合起來,優化好,就有了非常不錯的幀同步聯網效果,無論網絡速度如何,只要不是延遲大到變態,都保證了非常好的操作手感。

快照回滾的方式,也不是所有遊戲都適用,例如:

Skywind Inside " 再談網遊同步技術文章中對此模式(Time warp或Rollback)的缺點,也說明了。

http://www.skywind.me/blog/archives/1343#more-1343

如圖所述,這種模式不適合太多人的聯網玩法,例如MOBA,可能就不太適用。我們最多三人聯機,目前優化測試下來,效果也沒有太大問題。但是聯機人數越多,預測操作的錯誤可能性越大,導致的回滾也會越多。

一篇文章,難以講得面面俱到,很多地方可能描述也不一定明確,並且,個人能力有限,團隊人員有限(3個客戶端)的情況下,必定有很多設計實現不夠好的地方,大家見諒。

一些有幫助的文章再列一下:

1、Understanding Fighting Game Networking

http://mauve.mizuumi.net/2012/07/05/understanding-fighting-game-networking/

2、Skywind Inside " 再談網遊同步技術

http://www.skywind.me/blog/archives/1343#more-1343

3、《守望先鋒》回放技術:陣亡鏡頭、全場最佳和亮眼表現

https://mp.weixin.qq.com/s/cOGn8-rHWLIxdDz-R3pXDg

4、《王者榮耀》技術總監覆盤回爐歷程

http://youxiputao.com/articles/11842

5、幀同步:浮點精度測試

https://zhuanlan.zhihu.com/p/30422277

6、A guide to understanding netcode

https://www.gamereplays.org/overwatch/portals.php?show=page&name=overwatch-a-guide-to-understanding-netcode

7、網遊流暢基礎:幀同步遊戲開發

http://www.10tiao.com/html/255/201609/2650586281/4.html

最後囉嗦一句,如最開始所述,幀同步有很多變種、實現方式和優化方向。有時候,可能不同文章提到幀同步這個術語的時候,裏面的意思,可能都有區別,大家需要仔細理清和區分。


文末,再次感謝Gordon的分享,如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ羣:465082844)。

也歡迎大家來積極參與U Sparkle開發者計劃,簡稱"US",代表你和我,代表UWA和開發者在一起!

 

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