我們如何透過網路延遲補償演算法增強移動射擊遊戲的彈道電腦制

我們如何透過網路延遲補償演算法增強移動射擊遊戲的彈道電腦制

大家好,我是 Nikita Brizhak,Pixonic 的伺服器開發人員。 今天我想談談補償移動多人遊戲中的延遲。

很多文章都是關於伺服器延遲補償的,包括俄語的。 這並不奇怪,因為自 90 年代末以來,這項技術就被積極用於多人 FPS 的創建。 例如,您可能還記得《QuakeWorld》模組,它是最早使用它的模組之一。

我們還在我們的移動多人射擊遊戲 Dino Squad 中使用它。

在這篇文章中,我的目標不是重複已經寫了一千遍的內容,而是講述我們如何在遊戲中實現延遲補償,同時考慮到我們的技術堆疊和核心遊戲功能。

關於我們的大腦皮質和技術的幾句話。

Dino Squad 是一款網路行動 PvP 射擊遊戲。 玩家控製配備多種武器的恐龍,以6v6的組隊形式互相戰鬥。

客戶端和伺服器端均基於Unity。 對於射擊遊戲來說,該架構非常經典:伺服器是專制的,客戶端預測在客戶端上進行。 遊戲模擬是使用內部ECS編寫的,並在伺服器和客戶端上使用。

如果這是您第一次聽到滯後補償,這裡簡單介紹一下這個問題。

在多人 FPS 遊戲中,比賽通常在遠端伺服器上進行模擬。 玩家將他們的輸入(有關按下的按鍵的資訊)發送到伺服器,作為回應,伺服器會根據收到的資料向他們發送更新的遊戲狀態。 透過這種互動方案,按下前進鍵和玩家角色在螢幕上移動的時刻之間的延遲將始終大於 ping。

雖然在本地網路上這種延遲(通常稱為輸入延遲)可能不易察覺,但透過網路玩時,控制角色時會產生「在冰上滑動」的感覺。 這個問題與行動網路密切相關,玩家 ping 為 200 毫秒的情況仍然被認為是良好的連線。 通常 ping 可以是 350、500 或 1000 毫秒。 那麼,在輸入延遲的情況下玩快速射擊遊戲幾乎是不可能的。

解決這個問題的方法是客戶端模擬預測。 這裡,客戶端本身將輸入應用到玩家角色,而不等待伺服器的回應。 當收到答案時,它只是比較結果並更新對手的位置。 在這種情況下,按下按鍵和在螢幕上顯示結果之間的延遲是最小的。

理解這裡的細微差別很重要:客戶端總是根據其最後的輸入來繪製自己,而敵人 - 有網路延遲,根據伺服器資料的先前狀態。 也就是說,當向敵人射擊時,玩家會看到他相對於自己的過去。 有關客戶預測的更多信息 我們之前寫過.

這樣,客戶端預測解決了一個問題,卻又產生了另一個問題:如果玩家在敵人過去所在的點射擊,在伺服器上同一點射擊時,敵人可能已經不在那個地方了。 伺服器延遲補償試圖解決這個問題。 當武器開火時,伺服器會恢復玩家在開槍時在本地看到的遊戲狀態,並檢查他是否真的可以擊中敵人。 如果答案是“是”,則計算命中,即使此時敵人不再在伺服器上。

有了這些知識,我們開始在 Dino Squad 中實施伺服器延遲補償。 首先我們要明白如何在伺服器上恢復客戶端看到的內容? 到底需要恢復什麼? 在我們的遊戲中,武器和能力的命中是透過光線投射和疊加來計算的,也就是透過與敵人的物理碰撞器的互動來計算。 因此,我們需要在伺服器上重現玩家在本地「看到」的這些碰撞器的位置。 當時我們使用的是Unity 2018.x版本。 實體 API 是靜態的,物理世界存在於單一副本中。 無法保存其狀態然後從盒子中恢復它。 那該怎麼辦?

解決方案就在表面上;我們已經使用它的所有元素來解決其他問題:

  1. 對於每個客戶,我們需要知道他在什麼時候按下按鍵時看到了對手。 我們已經將此資訊寫入輸入包並使用它來調整客戶端預測。
  2. 我們需要能夠儲存遊戲狀態的歷史記錄。 正是在其中,我們將佔據對手(以及他們的碰撞者)的位置。 我們已經在伺服器上有了狀態歷史記錄,我們用它來構建 三角洲。 知道了正確的時間,我們就可以輕易地找到歷史上正確的狀態。
  3. 現在我們已經掌握了歷史中的遊戲狀態,我們需要能夠將玩家資料與物理世界的狀態同步。 現有的碰撞器 - 移動,遺失的碰撞器 - 創建,不必要的碰撞器 - 銷毀。 這個邏輯也已經寫好了,由幾個ECS系統組成。 我們使用它在一個 Unity 進程中容納多個遊戲室。 由於物理世界是每個進程一個,因此必須在房間之間重複使用。 在每次模擬之前,我們都會「重置」物理世界的狀態,並使用當前房間的資料重新初始化它,嘗試透過巧妙的池化系統盡可能地重複使用 Unity 遊戲物件。 剩下的就是為過去的遊戲狀態呼叫相同的邏輯。

透過將所有這些元素放在一起,我們得到了一台“時間機器”,可以將物理世界的狀態回滾到正確的時刻。 程式碼很簡單:

public class TimeMachine : ITimeMachine
{
     //История игровых состояний
     private readonly IGameStateHistory _history;

     //Текущее игровое состояние на сервере
     private readonly ExecutableSystem[] _systems;

     //Набор систем, расставляющих коллайдеры в физическом мире 
     //по данным из игрового состояния
     private readonly GameState _presentState;

     public TimeMachine(IGameStateHistory history, GameState presentState, ExecutableSystem[] timeInitSystems)
     {
         _history = history; 
         _presentState = presentState;
         _systems = timeInitSystems;  
     }

     public GameState TravelToTime(int tick)
     {
         var pastState = tick == _presentState.Time ? _presentState : _history.Get(tick);
         foreach (var system in _systems)
         {
             system.Execute(pastState);
         }
         return pastState;
     }
}

剩下的就是弄清楚如何使用這台機器來輕鬆補償投籃和能力。

在最簡單的情況下,當機制基於單一命中掃描時,一切似乎都很清楚:在玩家射擊之前,他需要將物理世界回滾到所需狀態,進行光線投射,計算命中或未命中,然後讓世界回到最初的狀態。

但恐龍小隊裡這樣的機制卻很少! 遊戲中的大多數武器都會產生彈頭 - 壽命長的子彈,可以飛行幾個模擬滴答聲(在某些情況下,可以飛行數十次)。 該怎麼處理它們,它們應該在什麼時間起飛?

В 古文 關於Half-Life網路堆疊,來自Valve的人問了同樣的問題,他們的答案是:彈丸滯後補償是有問題的,最好避免它。

我們沒有這個選擇:基於彈體的武器是遊戲設計的關鍵特徵。 所以我們必須想出一些辦法。 經過一番集思廣益後,我們制定了兩個似乎有效的選項:

1. 我們將彈體與創建它的玩家的時間連結起來。 伺服器模擬的每一次滴答,對於每個玩家的每一顆子彈,我們都會將物理世界回滾到客戶端狀態並執行必要的計算。 這種方法使得伺服器上的分散式負載和可預測的彈體飛行時間成為可能。 可預測性對我們來說尤其重要,因為我們在客戶端預測了所有彈體,包括敵方彈幕。

我們如何透過網路延遲補償演算法增強移動射擊遊戲的彈道電腦制
圖中,玩家在刻度 30 處預期發射導彈:他看到敵人正在朝哪個方向運行,並知道導彈的大致速度。 在本地,他看到自己在第 33 個刻度處達到了目標。 感謝延遲補償,它也會出現在伺服器上

2.我們所做的一切與第一個選項相同,但是,在計算了子彈模擬的一次滴答之後,我們不會停止,而是繼續在同一伺服器滴答內模擬其飛行,每次都使其時間更接近伺服器一一勾選並更新碰撞器位置。 我們這樣做直到發生以下兩種情況之一:

  • 子彈已經過期了。 這意味著計算結束了,我們可以算出未擊中或命中了。 而且這也是在開槍的同一時刻! 對我們來說,這既是優點也是缺點。 一個優點 - 因為對於射擊玩家來說,這顯著減少了命中和敵人生命值下降之間的延遲。 缺點是,當對手向玩家開火時,也會出現同樣的效果:敵人似乎只發射了一枚緩慢的火箭,而且傷害已經計算在內。
  • 子彈已到達伺服器時間。 在這種情況下,其模擬將在下一個伺服器時脈週期中繼續,而無需任何滯後補償。 對於慢速彈,與第一個選項相比,理論上這可以減少物理回滾的次數。 同時,模擬上的不均勻負載增加:伺服器要么空閒,要么在一個伺服器滴答中計算多個子彈的十幾個模擬滴答。

我們如何透過網路延遲補償演算法增強移動射擊遊戲的彈道電腦制
與上圖場景相同,但依照第二種方案計算。 導彈在射擊發生的同一時間點「趕上了」伺服器時間,並且命中最早可以在下一個時間點開始計算。 在第 31 個刻度處,在這種情況下,不再應用滯後補償

在我們的實作中,這兩種方法只有幾行程式碼不同,因此我們創建了這兩種方法,並且在很長一段時間內它們並行存在。 根據武器的力學和子彈的速度,我們為每種恐龍選擇了一個或另一個選項。 這裡的轉捩點是遊戲中出現了「在某某時間多次擊中敵人,獲得某某獎勵」的機制。 任何玩家擊中敵人的時間都扮演著重要角色的機制都拒絕使用第二種方法。 所以我們最終選擇了第一個選項,它現在適用於遊戲中的所有武器和所有主動能力。

另外,值得提出效能問題。 如果您認為所有這些都會減慢速度,我的回答是:確實如此。 Unity 在移動碰撞體以及打開和關閉它們方面非常緩慢。 在《恐龍小隊》中,在「最壞的情況」下,戰鬥中可能會同時存在數百枚彈頭。 移動對撞機來單獨計數每個彈頭是一種難以承受的奢侈。 因此,我們絕對有必要盡量減少物理「回滾」的次數。 為此,我們在 ECS 中創建了一個單獨的組件,用於記錄玩家的時間。 我們將其添加到所有需要滯後補償的實體(彈頭、能力等)中。 在我們開始處理這些實體之前,我們會將它們聚集在一起並一起處理,為每個集群回滾一次物理世界。

到了這個階段,我們已經有了一個大致可以運作的系統。 它的程式碼有點簡化:

public sealed class LagCompensationSystemGroup : ExecutableSystem
{
     //Машина времени
     private readonly ITimeMachine _timeMachine;

     //Набор систем лагкомпенсации
     private readonly LagCompensationSystem[] _systems;
     
     //Наша реализация кластеризатора
     private readonly TimeTravelMap _travelMap = new TimeTravelMap();

    public LagCompensationSystemGroup(ITimeMachine timeMachine, 
        LagCompensationSystem[] lagCompensationSystems)
     {
         _timeMachine = timeMachine;
         _systems = lagCompensationSystems;
     }

     public override void Execute(GameState gs)
     {
         //На вход кластеризатор принимает текущее игровое состояние,
         //а на выход выдает набор «корзин». В каждой корзине лежат энтити,
         //которым для лагкомпенсации нужно одно и то же время из истории.
         var buckets = _travelMap.RefillBuckets(gs);

         for (int bucketIndex = 0; bucketIndex < buckets.Count; bucketIndex++)
         {
             ProcessBucket(gs, buckets[bucketIndex]);
         }

         //В конце лагкомпенсации мы восстанавливаем физический мир 
         //в исходное состояние
         _timeMachine.TravelToTime(gs.Time);
     }

     private void ProcessBucket(GameState presentState, TimeTravelMap.Bucket bucket)
     {
         //Откатываем время один раз для каждой корзины
         var pastState = _timeMachine.TravelToTime(bucket.Time);

         foreach (var system in _systems)
         {
               system.PastState = pastState;
               system.PresentState = presentState;

               foreach (var entity in bucket)
               {
                   system.Execute(entity);
               }
          }
     }
}

剩下的就是配置詳細資訊:

1.隨時了解限制最大移動距離是多少。

對我們來說,讓遊戲在行動網路條件較差的情況下盡可能方便地存取非常重要,因此我們將故事限制為 30 個刻度(刻度率為 20 Hz)。 這使得玩家即使在非常高的 ping 值下也能擊中對手。

2. 確定哪些物體可以及時移動,哪些不能。

當然,我們正在推動我們的對手。 但例如可安裝的能量護盾則不然。 我們認為最好優先考慮防守能力,就像在線上射擊遊戲中經常做的那樣。 如果玩家已經在當前放置了盾牌,則過去的滯後補償子彈不應飛過它。

3.決定是否需要補償恐龍的能力:咬合、尾擊等。我們決定需要什麼,並依照與子彈相同的規則進行處理。

4. 決定如何處理正在執行滯後補償的玩家的碰撞器。 以一種好的方式,他們的位置不應該轉移到過去:玩家應該在他現在在伺服器上的同一時間看到自己。 然而,我們也回滾了射擊玩家的碰撞器,這有幾個原因。

首先,它改進了聚類:我們可以對所有接近 ping 值的玩家使用相同的物理狀態。

其次,在所有光線投射和重疊中,我們始終排除擁有能力或彈體的玩家的碰撞器。 在《Dino Squad》中,玩家控制恐龍,以射擊遊戲的標準,恐龍的幾何形狀相當不標準。 即使玩家以不尋常的角度射擊並且子彈的軌跡穿過玩家的恐龍對撞機,子彈也會忽略它。

第三,我們甚至在延遲補償開始之前就使用 ECS 的數據計算恐龍武器的位置或能力的應用點。

因此,滯後補償播放器的碰撞器的真實位置對我們來說並不重要,因此我們採取了一條更有效率、同時更簡單的路徑。

網路延遲不能簡單地消除,只能被屏蔽。 與任何其他偽裝方法一樣,伺服器延遲補償也有其權衡。 它改善了正在射擊的玩家的遊戲體驗,但以犧牲被射擊的玩家為代價。 然而,對於恐龍小隊來說,這裡的選擇是顯而易見的。

當然,這一切也必須以伺服器程式碼整體複雜性的增加為代價——對於程式設計師和遊戲設計師來說都是如此。 如果說早期的模擬是系統的簡單順序調用,那麼透過滯後補償,其中會出現巢狀循環和分支。 我們也花了很多精力來使其使用起來更加方便。

在 2019 年版本(也許更早)中,Unity 增加了對獨立物理場景的全面支援。 我們在更新後幾乎立即在伺服器上實現了它們,因為我們想快速擺脫所有房間共有的物理世界。

我們為每個遊戲房間提供了自己的實體場景,因此無需在計算模擬之前從相鄰房間的資料中「清除」場景。 首先,它顯著提高了生產力。 其次,如果程式設計師在添加新遊戲元素時在場景清理程式碼中犯了錯誤,那麼它可以消除一整類錯誤。 此類錯誤很難調試,並且通常會導致一個房間場景中的物理對象的狀態“流入”另一個房間。

另外,我們也研究了物理場景是否可以用來儲存物理世界的歷史。 也就是說,有條件地,為每個房間分配的不是一個場景,而是 30 個場景,並用它們製作一個循環緩衝區,用於儲存故事。 總的來說,這個選項被證明是有效的,但我們沒有實施它:它沒有顯示出生產力的任何瘋狂提高,但需要相當冒險的改變。 很難預測伺服器在如此多的場景下長時間工作時的表現。 因此,我們遵循以下規則:“如果還沒有破裂,請不要修復“。

來源: www.habr.com

添加評論