2020-09-03

FPS Sample服務端技術點


FPS Sample是一個由Unity官方出品的FPS類型的教程遊戲,整個教程遊戲的製作水準無論是從畫質還是網絡同步效果都是比較高的,並且完全開源。遊戲中有3個大的技術框架及其使用值得學習

  1. High-Definition Render Pipeline(HDRP).這是unity新推出的兩個渲染管線中的能夠取得較高畫質的一個,從遊戲的實際運行效果也可見一斑。
  2. Data-Oriented Technology Stack (DOTS).這也是unity力推的一套提升遊戲運行效率的技術,基本包括:①Entity Component System (ECS)機制 ②C# Job System ③burst compiler。youtube上面有一些測試視頻能看到有時DOTS能夠爲場景提供幾十倍的性能提升。
  3. 狀態同步機制。FPS Sample並沒有特別複雜的服務端架構,僅僅是客戶端連接上服務端,兩端同步指令和狀態數據。這個同步的過程正式這篇文章想要描述的主要內容,下文以每一個技術點爲小節展開。

基本信息

考慮到實時性,FPS Sample也使用了UDP作爲戰場內數據同步的傳輸層協議,爲了防止丟包,做了ACK機制和數據重傳機制。這個數據重傳並不和TCP一樣丟了某一幀的數據就再把那一幀的數據重發一份完全一樣的,畢竟在實時性很強的射擊遊戲裏面,重傳之前的老數據的意義也不大。FPS做的重傳方式是將重要信息重傳,譬如服務端想要下發生成entityA的一個命令,服務端會一直向發往客戶端數據包中寫入創建一個entityA的命令,直至收到客戶端回覆了創建entityA的ACK。在服務端做的另一個方面就是儘量使用一些持續的狀態,而不是一個瞬發事件。客戶端對於防丟包做法就是傳輸冗餘數據,每一幀向服務端發送用戶輸入的時候把之前兩幀的用戶輸入再次傳輸。由於用戶輸入數據量很小,重複傳輸用戶輸入可以很有效的防止丟包,如果一次丟包的概率是 1 0 5 10^{-5} ,那三次都丟包的概率就只有 1 0 15 10^{-15} 了。

#客戶端預測回滾
在FPS Sample中,服務端有着絕對的權威,也就是說客戶端並不會上傳任何本地信息譬如英雄的血量、位置等,而是由服務端下發到客戶端。但是這樣就會造成一個問題:客戶端接收到用戶的輸入並上傳到服務端,服務端處理之後將整個數據快照發放到客戶端這樣一個流程至少需要一個rtt (Round-Trip Time),這還是在忽略服務端處理時間的情況下。這樣的話很多時候都會給用戶一種很卡的感覺。爲了解決上述問題,FPS Sampe使用了比較常見的「預測回滾機制」。預測回滾機制包含兩個部分,①一個是預測:客戶端接受到用戶的輸入之後一方面將用戶輸入上傳到服務端另一方面會按照和服務端一致的邏輯執行用戶輸入,這樣的話無需等到服務端回覆消息玩家控制的角色就可以響應用戶輸入,能夠有效的提升用戶體驗。②另一個是回滾:單單有預測的話可想而知會造成客戶端和服務端數據不一致,所以當服務端快照到達的時候,客戶端會先將玩家控制角色的狀態設置和服務端完全一致,然後將服務端還未處理的(因爲rtt的存在)消息再走一遍預測邏輯。預測回滾機制只對本地玩家控制的角色生效,並不會對其他玩家控制的角色(也就是敵人)起作用,並且在預測的時候並不會創建新的entity,譬如在用戶按下鼠標開槍的時候,客戶端僅會播放開槍的動畫而子彈entity的生成還是需要等到服務端通知纔會發生。
爲了定位哪些消息是服務端處理了的哪些消息服務端還未處理,客戶端會維護幾個變量,名稱和含義如下表所示,這幾個變量的主要邏輯都存在與ClientGameLoop.HandleTime(float frameDuration)中,這個函數在每個渲染幀(也就是Unity的MonoBehavior.Update)中都會被調用。

變量名稱 作用
rtt Roud-Trip time,和計算機網絡中的rtt是同一個概念,就是平均情況下數據包從客戶端到服務端再到客戶端的所用的時間
serverTime 客戶端記錄的最近一次收到的有效數據包中記錄的服務端的tick值,並且這個值在下次收到有效數據包之前不會發生變化
PredictedTime 客戶端進行「預測回滾」以及「發送指令數據包」時使用的一個時間。PredictedTime是一個浮動的值在每一次HandleTime調用的時候,PredictedTime會增加frameDuration * frameTimeScale,其中frameTimeScale的值在 P r e d i c t e d T i m e < s e r v e r T i m e + r t t PredictedTime < serverTime + rtt 時等於1.01,在 P r e d i c t e d T i m e > s e r v e r T i m e + r t t PredictedTime > serverTime + rtt 時等於0.99,這樣來看PredictedTime的值基本可表示爲 P r e c i t e d T i m e = s e r v e r T i m e + r t t PrecitedTime = serverTime + rtt

如此設計的目的是讓PredictedTime的含義爲「發送的用戶輸入到達服務端的時間」,這樣就可以很方便地在客戶端以PredictedTime爲索引去收集、存儲和發送客戶端用戶輸入。以下圖爲例,假設rtt=6(單位是tick),在server tick=13這個時刻,客戶端剛收到了一個在server tick=10發來的服務端消息,所以serverTime=10,所以PredictedTime=10+6=16。此時基本可以確定PredictedTime<10的數據包服務端都已經處理了,在預測的時候只需要處理 P r e d i c t e d T i m e [ 11 , 16 ] PredictedTime \in [11,16] 這個區間裏面的客戶端輸入就行了。
1
FPS中對應的源碼ClientGameLoop.ClientGameWord.Update截圖如下。其中PredictedRollback會將整個遊戲狀態回滾到上一次服務端發送過來的狀態,然後在for loop中運行所有服務端未確認接受的客戶端指令。
2

預測回滾在處理ClientGameLoop.ClientGameWord.Update;時間相關變量處理在ClientGameLoop.HandleTime

###客戶端插值
對於其他玩家控制的角色,如果僅僅是在服務端數據達到的時候更新本地狀態就會造成即使客戶端性能非常好能夠跑到200FPS,如果服務端tick rate=30那麼其他玩家控制的角色也僅僅會每秒更新30次而不是200次。這樣明顯會給玩家造成「卡頓」直觀感受。爲了解決這個問題FPS Sample使用了客戶端插值技術。簡單來說就是在接受到服務端快照的時候,並不會立馬把角色屬性設置到角色身上,而是在一個tick的時間間隔通過線性插值逐漸的將角色的狀態從上一個tick的值改變成爲這一個tick的值,效果如下動圖。線性插值僅針對其他玩家控制的角色,不對本地玩家控制的角色生效客戶端會維護一個RenderTime,用來記錄當前插值到哪一個tick了,RenderTime基本上等於serverTime。有的時候可能會一次到達多個服務端快照,這時客戶端會加速插值的過程,此外RenderTime還用在下一小節「服務端延遲補償」中。
3

線性插值的入口代碼代碼在ClientGameLoop.update中的m_ReplicatedEntityModule.Interpolate(m_RenderTime);;實際執行插值邏輯的代碼在InterpolatedComponentSerializer.Interpolate

###服務端延遲補償
由於網絡延遲和客戶端插值的存在,本地玩家看到其他玩家控制的角色的狀態總比服務器對應的角色晚一些,且時間間隔至少是1/2個rtt。試想這樣一種情況,玩家A控制角色瞄準了一個牆縫伺機攻擊敵人,玩家B控制角色從上往下走。在玩家A的視角中B恰好走到自己的準星上,A就會扣動扳機開槍。玩家A有沒有擊中B,是由服務端計算判斷的,但是在服務端存儲的數據中,由於延遲的存在玩家A開槍的時候玩家B其實已經從牆縫這裏走過去了,也就是說A無法擊中B。這對於A來說完全無法接受,因爲從他的視角中來看他是能夠恰好擊中B的。
4
FPS Sample這裏的做法是客戶端在向服務端發送用戶輸入的時候會將RenderTime一併發送過去。服務端會記錄下來每一個tick每個entity的所有碰撞體的位置,在進行受擊測試的時候使用客戶端發送過來的RenderTime取到歷史記錄中的碰撞體位置來進行判斷是否能夠擊中。這樣做對於玩家A的射擊體驗會有很大的提升,但是對於玩家B來說可能就會那麼「友好」。如果A的客戶端和服務器之間的rtt較大、B和服務器之前的rtt較小,從B的感官上來說可能會出現他已經走過牆縫很遠了但是還是被A開槍打死了。所以這裏可以添加一個判斷如果客戶端上傳的RenderTime和實際的server tick相差較大就忽略這個RenderTime而使用實際的server tick進行受擊判斷。

記錄entity每一幀碰撞體的入口代碼在StoreColliderStates.Update中;在ProjectileSystemShared.CreateProjectileMovementCollisionQueries中能夠看到在創建RaySphereQueryReceiver.Query的時候,將Query.hitCollisionTestTick設置爲command.renderTick,也就是客戶端的renderTime.tick,並且可以看到在後續的碰撞測試中,會通過hitCollisionTestTick字段去讀取可發生碰撞的entity的collider的歷史位置,通過和歷史位置發生碰撞來進行延遲補償。

###差值快照
FPS Sample團隊在youtube的分享視頻中花了較大的篇幅介紹了「Delta encoding using frame prediction」技術點(中文姑且叫快照壓縮吧)。以前也有類似的技術,只不過都是基於1箇舊的客戶端已經確認收到的快照和最新快照來計算差量,FPS Sample中用到的技術是使用3箇舊的快照和新的快照來計算差量。如下圖示假如服務端想把快照83發送給客戶端。服務端首先查看一下客戶端已經確認接收的最近的3個快照分別是82、81和80,服務端將這三個快照爲參數根據一個預測算法計算出來了一個預測快照 8 3 p 83_p ,然後計算一個預測快照和想要發送的快照之間的差值,並將將這個差值發送給客戶端。客戶端根據自己收到的快照82、81、80以及相同的快照預測算法計算出來一個和服務端相同的預測快照,然後根據服務端傳來的差值反推出實際的快照83。
5

具體代碼邏輯比較繁瑣,這裏就不再贅述。預測算法在NetworkPrediction.PredictSnapshot()中;客戶端寫入快照差值在NetworkServer.WriteSnaptshot的DeltaWriter.Write處;客戶端和服務端共用預測算法,讀取快照差值NetworkClient.ReadSnapshot的DeltaRead.Read處

這裏還是有一些可以做的東西。以某個角色的位置爲例,位置的變化可以歸類爲4中趨勢:勻速、勻加速、靜止(沒變化)和不可根據已有快照預測的變化。這樣的話可以用兩個bit(FPS Sampe中只有一個bit用來表示是否和預測快照一致)分別表示這四種狀態,並且只有在第四種「不可預測」的情況下,才需要將最新的快照數據傳輸到客戶端。