Jak ulepszyliśmy mechanikę obliczeń balistycznych dla mobilnej strzelanki o algorytm kompensacji opóźnień sieci

Jak ulepszyliśmy mechanikę obliczeń balistycznych dla mobilnej strzelanki o algorytm kompensacji opóźnień sieci

Cześć, jestem Nikita Brizhak, programista serwerów w Pixonic. Dzisiaj chciałbym porozmawiać o kompensowaniu lagów w mobilnym trybie wieloosobowym.

Na temat kompensacji opóźnień serwera napisano wiele artykułów, w tym w języku rosyjskim. Nie jest to zaskakujące, ponieważ technologia ta jest aktywnie wykorzystywana przy tworzeniu wieloosobowych FPS-ów od końca lat 90-tych. Pamiętacie na przykład mod QuakeWorld, który jako jeden z pierwszych z niego skorzystał.

Używamy go również w naszej mobilnej strzelance wieloosobowej Dino Squad.

W tym artykule moim celem nie jest powtarzanie tego, co zostało już napisane tysiąc razy, ale opowiedzenie, jak zaimplementowaliśmy kompensację opóźnień w naszej grze, biorąc pod uwagę nasz stos technologii i podstawowe funkcje rozgrywki.

Kilka słów o naszej korze mózgowej i technologii.

Dino Squad to sieciowa mobilna strzelanka PvP. Gracze kontrolują dinozaury wyposażone w różnorodną broń i walczą między sobą w drużynach 6 na 6.

Zarówno klient, jak i serwer oparte są na Unity. Architektura jest dość klasyczna dla strzelanek: serwer jest autorytarny, a przewidywanie klientów działa na klientach. Symulacja gry została napisana przy użyciu własnego ECS i jest używana zarówno na serwerze, jak i kliencie.

Jeśli po raz pierwszy słyszysz o kompensacji opóźnień, oto krótkie przybliżenie tego zagadnienia.

W wieloosobowych grach FPS mecz jest zwykle symulowany na zdalnym serwerze. Gracze wysyłają swoje dane wejściowe (informacje o naciśniętych klawiszach) do serwera, a w odpowiedzi serwer przesyła im zaktualizowany stan gry, uwzględniający otrzymane dane. W tym schemacie interakcji opóźnienie pomiędzy naciśnięciem klawisza „do przodu” a momentem, w którym postać gracza porusza się na ekranie, będzie zawsze większe niż ping.

O ile w sieciach lokalnych to opóźnienie (popularnie zwane input lagiem) może być niezauważalne, o tyle podczas gry przez Internet stwarza wrażenie „ślizgania się po lodzie” podczas sterowania postacią. Problem ten jest podwójnie istotny w przypadku sieci komórkowych, gdzie przypadek, gdy ping gracza wynosi 200 ms, nadal jest uważany za doskonałe połączenie. Często ping może wynosić 350, 500 lub 1000 ms. Wtedy gra w szybką strzelankę z opóźnieniem wejściowym staje się prawie niemożliwa.

Rozwiązaniem tego problemu jest przewidywanie symulacji po stronie klienta. Tutaj klient sam stosuje dane wejściowe do postaci gracza, nie czekając na odpowiedź z serwera. A kiedy otrzyma odpowiedź, po prostu porównuje wyniki i aktualizuje pozycje przeciwników. Opóźnienie pomiędzy naciśnięciem klawisza a wyświetleniem wyniku na ekranie jest w tym przypadku minimalne.

Ważne jest, aby zrozumieć niuans: klient zawsze rysuje się według swojego ostatniego wejścia, a wrogowie - z opóźnieniem sieci, zgodnie z poprzednim stanem z danych z serwera. Oznacza to, że strzelając do wroga, gracz widzi go w przeszłości względem siebie. Więcej o przewidywaniu klientów pisaliśmy wcześniej.

Zatem przewidywanie klienta rozwiązuje jeden problem, ale stwarza kolejny: jeśli gracz strzela w miejsce, w którym w przeszłości znajdował się wróg, na serwerze podczas strzelania w tym samym miejscu, wroga może już nie być w tym miejscu. Kompensacja opóźnień serwera próbuje rozwiązać ten problem. Po oddaniu strzału serwer przywraca stan gry, jaki gracz widział lokalnie w momencie oddania strzału i sprawdza, czy rzeczywiście mógł trafić wroga. Jeśli odpowiedź brzmi „tak”, trafienie jest liczone, nawet jeśli wroga w tym momencie nie ma już na serwerze.

Uzbrojeni w tę wiedzę zaczęliśmy wdrażać kompensację lagów serwera w Dino Squad. Przede wszystkim musieliśmy zrozumieć, jak przywrócić na serwerze to, co zobaczył klient? A co dokładnie trzeba odnowić? W naszej grze trafienia z broni i umiejętności są obliczane poprzez promienie i nakładki, czyli poprzez interakcje z fizycznymi zderzaczami wroga. W związku z tym musieliśmy odtworzyć na serwerze położenie tych zderzaczy, które gracz „widział” lokalnie. W tym czasie korzystaliśmy z wersji Unity 2018.x. API fizyki jest tam statyczne, świat fizyczny istnieje w jednej kopii. Nie ma możliwości zapisania jego stanu i późniejszego przywrócenia go z pudełka. Co więc zrobić?

Rozwiązanie leżało na powierzchni, wszystkie jego elementy zostały już przez nas wykorzystane do rozwiązania innych problemów:

  1. Dla każdego klienta musimy wiedzieć, o której godzinie zobaczył przeciwników, kiedy nacisnął klawisze. Zapisaliśmy już te informacje w pakiecie wejściowym i wykorzystaliśmy je do dostosowania przewidywań klienta.
  2. Musimy być w stanie przechowywać historię stanów gry. To w nim będziemy utrzymywać pozycje naszych przeciwników (a co za tym idzie ich zderzaczy). Na serwerze mieliśmy już historię stanu, wykorzystaliśmy ją do zbudowania delty. Znając właściwy czas, z łatwością moglibyśmy znaleźć odpowiedni stan w historii.
  3. Teraz, gdy mamy już pod ręką stan gry z historii, musimy mieć możliwość synchronizacji danych graczy ze stanem świata fizycznego. Istniejące zderzacze - przenieś, brakujące - stwórz, niepotrzebne - zniszcz. Ta logika również została już napisana i składała się z kilku systemów ECS. Użyliśmy go do utrzymania kilku pokoi gier w jednym procesie Unity. A ponieważ świat fizyczny przypada na jeden proces, trzeba było go ponownie wykorzystywać między pomieszczeniami. Przed każdym zaznaczeniem symulacji „resetujemy” stan świata fizycznego i ponownie inicjujemy go danymi z bieżącego pokoju, starając się w jak największym stopniu ponownie wykorzystać obiekty gry Unity poprzez sprytny system łączenia. Pozostało tylko odwołać się do tej samej logiki dla stanu gry z przeszłości.

Łącząc te wszystkie elementy w jedną całość otrzymaliśmy „wehikuł czasu”, który potrafił cofnąć stan świata fizycznego do odpowiedniego momentu. Kod okazał się prosty:

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

Pozostało tylko wymyślić, jak używać tej maszyny, aby łatwo kompensować strzały i umiejętności.

W najprostszym przypadku, gdy mechanika opiera się na pojedynczym trafieniu, wszystko wydaje się jasne: zanim gracz odda strzał, musi przywrócić świat fizyczny do pożądanego stanu, wykonać raycast, policzyć trafienia lub chybienia i przywrócić świat do stanu początkowego.

Ale takich mechanik w Dino Squad jest bardzo mało! Większość broni w grze tworzy pociski – długowieczne kule, które latają przez kilka symulacyjnych taktów (w niektórych przypadkach dziesiątki taktów). Co z nimi zrobić, o której godzinie mają lecieć?

В starożytny artykuł jeśli chodzi o stos sieciowy Half-Life, goście z Valve zadali to samo pytanie, a ich odpowiedź była następująca: kompensacja opóźnienia pocisku jest problematyczna i lepiej jej unikać.

Nie mieliśmy takiej opcji: broń oparta na pociskach była kluczowym elementem projektu gry. Musieliśmy więc coś wymyślić. Po burzy mózgów sformułowaliśmy dwie opcje, które wydawały się działać:

1. Pocisk wiążemy z czasem gracza, który go stworzył. Z każdym tyknięciem symulacji serwera, dla każdego pocisku każdego gracza przywracamy świat fizyczny do stanu klienta i wykonujemy niezbędne obliczenia. Takie podejście umożliwiło rozłożenie obciążenia serwera i przewidywalny czas lotu pocisków. Przewidywalność była dla nas szczególnie ważna, ponieważ mamy wszystkie pociski, w tym pociski wroga, przewidywane na kliencie.

Jak ulepszyliśmy mechanikę obliczeń balistycznych dla mobilnej strzelanki o algorytm kompensacji opóźnień sieci
Na obrazku gracz w punkcie 30 wystrzeliwuje rakietę w oczekiwaniu: widzi, w którym kierunku biegnie wróg i zna przybliżoną prędkość pocisku. Lokalnie widzi, że trafił w cel w 33. tyg. Dzięki kompensacji lagów pojawi się również na serwerze

2. Robimy wszystko tak samo jak w pierwszym wariancie, jednak po odliczeniu jednego tiku symulacji pocisku nie zatrzymujemy się, lecz kontynuujemy symulację jego lotu w obrębie tego samego tika serwera, za każdym razem przybliżając jego czas do serwera zaznaczanie jeden po drugim i aktualizowanie pozycji zderzaczy. Robimy to, dopóki nie wydarzy się jedna z dwóch rzeczy:

  • Kula wygasła. Oznacza to, że obliczenia się skończyły, możemy liczyć na chybienie lub trafienie. I to w tym samym momencie, w którym padł strzał! Dla nas był to zarówno plus, jak i minus. Plus – ponieważ dla strzelającego gracza znacznie zmniejsza to opóźnienie pomiędzy trafieniem, a spadkiem zdrowia wroga. Wadą jest to, że ten sam efekt zaobserwowano, gdy przeciwnicy strzelali do gracza: wydawałoby się, że wróg wystrzelił tylko powolną rakietę, a obrażenia zostały już policzone.
  • Pocisk osiągnął czas serwera. W takim przypadku jego symulacja będzie kontynuowana w następnym takcie serwera bez żadnej kompensacji opóźnienia. W przypadku powolnych pocisków mogłoby to teoretycznie zmniejszyć liczbę cofnięć fizycznych w porównaniu z pierwszą opcją. Jednocześnie wzrosło nierównomierne obciążenie symulacji: serwer albo był bezczynny, albo w jednym takcie serwera obliczał kilkanaście taktów symulacji dla kilku kul.

Jak ulepszyliśmy mechanikę obliczeń balistycznych dla mobilnej strzelanki o algorytm kompensacji opóźnień sieci
Ten sam scenariusz co na poprzednim obrazku, ale obliczony według drugiego schematu. Pocisk „dogonił” czas serwera w tym samym momencie, w którym nastąpił strzał, a trafienie można zaliczyć już w następnym takcie. W tym przypadku po 31. takcie kompensacja opóźnienia nie jest już stosowana

W naszej implementacji te dwa podejścia różniły się zaledwie kilkoma linijkami kodu, więc stworzyliśmy oba i przez długi czas istniały równolegle. W zależności od mechaniki broni i prędkości pocisku wybraliśmy jedną lub drugą opcję dla każdego dinozaura. Punktem zwrotnym było tutaj pojawienie się w grze mechaniki typu „jeśli trafisz wroga tyle razy w takim a takim czasie, zdobądź taki a taki bonus”. Jakakolwiek mechanika, w której czas uderzenia gracza odgrywał ważną rolę, nie sprawdzała się w przypadku drugiego podejścia. Ostatecznie wybraliśmy pierwszą opcję, która teraz ma zastosowanie do wszystkich broni i wszystkich aktywnych umiejętności w grze.

Osobno warto poruszyć kwestię wydajności. Jeśli myśleliście, że to wszystko spowolni bieg wydarzeń, odpowiadam: tak. Unity dość wolno przesuwa zderzacze oraz włącza je i wyłącza. W Dino Squad w „najgorszym” przypadku w walce może znajdować się jednocześnie kilkaset pocisków. Ruchome zderzacze, które zliczają każdy pocisk z osobna, to luksus, na który nie można sobie pozwolić. Dlatego absolutnie konieczne było dla nas zminimalizowanie liczby „cofnięć” w fizyce. W tym celu stworzyliśmy w ECS osobny komponent, w którym rejestrujemy czas gracza. Dodaliśmy go do wszystkich obiektów wymagających kompensacji opóźnień (pociski, umiejętności itp.). Zanim zaczniemy przetwarzać takie byty, do tego czasu grupujemy je i przetwarzamy razem, cofając świat fizyczny raz dla każdego klastra.

Na tym etapie mamy ogólnie działający system. Jego kod w nieco uproszczonej formie:

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

Pozostało tylko skonfigurować szczegóły:

1. Zrozum, jak bardzo należy ograniczyć maksymalną odległość ruchu w czasie.

Ważne było dla nas, aby gra była jak najbardziej przystępna w warunkach słabych sieci komórkowych, dlatego ograniczyliśmy historię marginesem 30 taktów (przy częstotliwości taktów 20 Hz). Dzięki temu gracze mogą uderzać przeciwników nawet przy bardzo wysokich pingach.

2. Określ, które obiekty można przesuwać w czasie, a które nie.

My oczywiście poruszamy naszych przeciwników. Ale na przykład instalowalne osłony energetyczne już takie nie są. Zdecydowaliśmy, że lepiej będzie dać pierwszeństwo umiejętnościom defensywnym, jak to często ma miejsce w strzelankach sieciowych. Jeśli gracz umieścił już tarczę w teraźniejszości, kule z przeszłości z kompensacją opóźnienia nie powinny przez nią przelatywać.

3. Zdecyduj, czy konieczne jest kompensowanie zdolności dinozaurów: ugryzienie, uderzenie ogonem itp. Zdecydowaliśmy, co jest potrzebne i przetwarzamy je według tych samych zasad, co kule.

4. Ustal, co zrobić z colliderami odtwarzacza, dla którego wykonywana jest kompensacja lagów. W dobrym tego słowa znaczeniu, ich pozycja nie powinna przesuwać się w przeszłość: gracz powinien zobaczyć siebie w tym samym czasie, w którym znajduje się teraz na serwerze. Cofamy jednak również zderzacze strzelającego gracza i jest ku temu kilka powodów.

Po pierwsze, poprawia klastrowanie: możemy używać tego samego stanu fizycznego dla wszystkich graczy z bliskimi pingami.

Po drugie, we wszystkich promieniach i nakładaniu się zawsze wykluczamy zderzacze gracza, który posiada umiejętności lub pociski. W Dino Squad gracze kontrolują dinozaury, które jak na standardy strzelanek mają dość niestandardową geometrię. Nawet jeśli gracz strzela pod nietypowym kątem, a trajektoria pocisku przechodzi przez zderzacz dinozaura gracza, kula go zignoruje.

Po trzecie, obliczamy pozycje broni dinozaura lub punkt zastosowania umiejętności, korzystając z danych z ECS jeszcze przed rozpoczęciem kompensacji opóźnienia.

W rezultacie rzeczywiste położenie zderzaczy odtwarzacza z kompensacją opóźnień jest dla nas nieistotne, dlatego wybraliśmy bardziej produktywną, a jednocześnie prostszą ścieżkę.

Opóźnienia sieci nie można po prostu usunąć, można je jedynie zamaskować. Jak każda inna metoda maskowania, kompensacja opóźnień serwera ma swoje wady. Poprawia wrażenia z gry gracza, który strzela kosztem gracza, do którego strzela się. Dla Dino Squad wybór był jednak tutaj oczywisty.

Oczywiście za to wszystko trzeba było zapłacić zwiększoną złożonością całego kodu serwera - zarówno dla programistów, jak i projektantów gier. Jeśli wcześniej symulacja była prostym sekwencyjnym wywołaniem systemów, to z kompensacją opóźnienia pojawiły się w niej zagnieżdżone pętle i gałęzie. Wiele wysiłku włożyliśmy także w to, aby praca z nim była wygodna.

W wersji 2019 (a może trochę wcześniej) Unity dodało pełną obsługę niezależnych scen fizycznych. Wdrożyliśmy je na serwerze niemal od razu po aktualizacji, gdyż chcieliśmy szybko pozbyć się świata fizycznego wspólnego dla wszystkich pomieszczeń.

Każdemu pokojowi gier nadaliśmy własną fizyczną scenę, eliminując w ten sposób potrzebę „czyszczenia” sceny z danych sąsiedniego pokoju przed obliczeniem symulacji. Po pierwsze, dało to znaczny wzrost produktywności. Po drugie, pozwoliło to pozbyć się całej klasy błędów, które pojawiały się, gdy programista popełnił błąd w kodzie czyszczącym scenę podczas dodawania nowych elementów gry. Takie błędy były trudne do naprawienia i często powodowały, że stan obiektów fizycznych w scenie jednego pomieszczenia „przepływał” do innego pokoju.

Ponadto przeprowadziliśmy badania, czy sceny fizyczne można wykorzystać do przechowywania historii świata fizycznego. Oznacza to, że warunkowo przydziel nie jedną scenę do każdego pokoju, ale 30 scen i utwórz z nich cykliczny bufor, w którym będzie przechowywana historia. Ogólnie rzecz biorąc, opcja okazała się skuteczna, ale jej nie wdrożyliśmy: nie wykazała żadnego szalonego wzrostu produktywności, ale wymagała dość ryzykownych zmian. Trudno było przewidzieć, jak serwer będzie się zachowywał, pracując przez długi czas z tak dużą liczbą scen. Kierowaliśmy się zatem zasadą: „Jeśli nie jest zepsuty, nie naprawiaj tego".

Źródło: www.habr.com

Dodaj komentarz