Ako sme vylepšili mechaniku balistických výpočtov pre mobilnú strieľačku pomocou algoritmu kompenzácie latencie siete

Ako sme vylepšili mechaniku balistických výpočtov pre mobilnú strieľačku pomocou algoritmu kompenzácie latencie siete

Ahoj, som Nikita Brizhak, vývojár serverov od Pixonic. Dnes by som chcel hovoriť o kompenzácii oneskorenia v mobilnom multiplayeri.

O kompenzácii oneskorenia servera bolo napísaných veľa článkov, a to aj v ruštine. To nie je prekvapujúce, pretože táto technológia sa aktívne používa pri vytváraní multiplayerových FPS od konca 90-tych rokov. Môžete si napríklad spomenúť na mod QuakeWorld, ktorý ho ako jeden z prvých použil.

Používame ho aj v našej mobilnej multiplayerovej strieľačke Dino Squad.

V tomto článku nie je mojím cieľom opakovať to, čo už bolo napísané tisíckrát, ale povedať, ako sme implementovali kompenzáciu oneskorenia v našej hre, berúc do úvahy náš technologický balík a základné herné funkcie.

Pár slov o našej kôre a technológii.

Dino Squad je sieťová mobilná PvP strieľačka. Hráči ovládajú dinosaurov vybavených rôznymi zbraňami a bojujú medzi sebou v tímoch 6v6.

Klient aj server sú založené na Unity. Architektúra je pre strelcov celkom klasická: server je autoritatívny a predikcia klienta funguje na klientoch. Simulácia hry je napísaná pomocou interného ECS a používa sa na serveri aj klientovi.

Ak o kompenzácii oneskorenia počujete prvýkrát, tu je krátky exkurz do problematiky.

V hrách FPS pre viacerých hráčov sa zápas zvyčajne simuluje na vzdialenom serveri. Hráči posielajú svoje vstupy (informácie o stlačených klávesoch) na server a ako odpoveď im server pošle aktualizovaný stav hry s prihliadnutím na prijaté dáta. Pri tejto schéme interakcie bude oneskorenie medzi stlačením tlačidla dopredu a momentom pohybu postavy hráča na obrazovke vždy väčšie ako ping.

Kým na lokálnych sieťach môže byť toto oneskorenie (ľudovo povedané input lag) nepostrehnuteľné, pri hraní cez internet vytvára pri ovládaní postavy pocit „kĺzania po ľade“. Tento problém je dvojnásobne relevantný pre mobilné siete, kde prípad, keď je ping hráča 200 ms, sa stále považuje za vynikajúce spojenie. Ping môže mať často 350, 500 alebo 1000 ms. Potom je takmer nemožné hrať rýchlu strieľačku so vstupným oneskorením.

Riešením tohto problému je predikcia simulácie na strane klienta. Tu klient sám aplikuje vstup na postavu hráča bez toho, aby čakal na odpoveď zo servera. A keď dostane odpoveď, jednoducho porovná výsledky a aktualizuje pozície súperov. Oneskorenie medzi stlačením klávesu a zobrazením výsledku na obrazovke je v tomto prípade minimálne.

Tu je dôležité pochopiť nuansu: klient sa vždy kreslí podľa svojho posledného vstupu a nepriatelia - so sieťovým oneskorením, podľa predchádzajúceho stavu z údajov zo servera. To znamená, že keď strieľate na nepriateľa, hráč ho vidí v minulosti relatívne k sebe. Viac o predikcii klientov písali sme skôr.

Predikcia klienta teda rieši jeden problém, no vytvára ďalší: ak hráč strieľa v bode, kde bol v minulosti nepriateľ, na serveri pri streľbe v tom istom bode, nepriateľ už nemusí byť na tomto mieste. Tento problém sa pokúša vyriešiť kompenzácia oneskorenia servera. Keď dôjde k výstrelu zo zbrane, server obnoví herný stav, ktorý hráč videl lokálne v čase výstrelu, a skontroluje, či skutočne mohol zasiahnuť nepriateľa. Ak je odpoveď „áno“, zásah sa počíta, aj keď nepriateľ už nie je na serveri.

Vyzbrojení týmito znalosťami sme začali implementovať kompenzáciu oneskorenia servera v Dino Squad. Najprv sme museli pochopiť, ako obnoviť na serveri to, čo klient videl? A čo konkrétne je potrebné obnoviť? V našej hre sa zásahy zo zbraní a schopností počítajú prostredníctvom raycastov a prekrytí – teda prostredníctvom interakcií s fyzickými kolidérmi nepriateľa. V súlade s tým sme potrebovali reprodukovať polohu týchto zrážačov, ktoré hráč „videl“ lokálne, na serveri. V tom čase sme používali Unity verziu 2018.x. Fyzikálne API je statické, fyzický svet existuje v jedinej kópii. Neexistuje spôsob, ako uložiť jeho stav a potom ho obnoviť z krabice. Čo teda robiť?

Riešenie bolo na povrchu, všetky jeho prvky sme už použili na riešenie iných problémov:

  1. Pre každého klienta potrebujeme vedieť, v akom čase videl súperov, keď stlačil klávesy. Tieto informácie sme už zapísali do vstupného balíka a použili na úpravu predikcie klienta.
  2. Musíme byť schopní ukladať históriu herných stavov. Práve v ňom budeme držať pozície našich súperov (a teda aj ich narážačov). Históriu stavu sme už na serveri mali, použili sme ju na zostavenie delty. Poznaním správneho času by sme ľahko našli správny stav v histórii.
  3. Teraz, keď máme v rukách stav hry z histórie, musíme byť schopní synchronizovať dáta hráča so stavom fyzického sveta. Existujúce kolízie - pohyb, chýbajúce - vytváranie, nepotrebné - ničenie. Táto logika už bola tiež napísaná a pozostávala z niekoľkých systémov ECS. Použili sme to na usporiadanie niekoľkých herní v jednom procese Unity. A keďže fyzický svet je jeden na proces, musel sa znova použiť medzi miestnosťami. Pred každým tiknutím simulácie sme „resetovali“ stav fyzického sveta a reinicializovali ho s údajmi pre aktuálnu miestnosť, pričom sme sa prostredníctvom šikovného systému združovania snažili čo najviac opätovne použiť objekty hry Unity. Ostávalo už len vyvolať rovnakú logiku pre herný stav z minulosti.

Spojením všetkých týchto prvkov sme dostali „stroj času“, ktorý dokázal vrátiť stav fyzického sveta späť do správneho okamihu. Kód sa ukázal byť jednoduchý:

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

Ostávalo už len vymyslieť, ako tento stroj využiť na ľahké vyrovnanie záberov a schopností.

V najjednoduchšom prípade, keď je mechanika založená na jedinom hitscan, sa zdá byť všetko jasné: predtým, ako hráč vystrelí, musí vrátiť fyzický svet do požadovaného stavu, urobiť raycast, spočítať zásah alebo netrafenie a vrátiť svet do pôvodného stavu.

Ale takých mechanikov je v Dino Squad veľmi málo! Väčšina zbraní v hre vytvára projektily – guľky s dlhou životnosťou, ktoré lietajú na niekoľko simulačných tikov (v niektorých prípadoch desiatky tikov). Čo s nimi robiť, kedy by mali letieť?

В starodávny článok o Half-Life network stack sa chlapci z Valve pýtali rovnakú otázku a ich odpoveď bola takáto: kompenzácia oneskorenia projektilu je problematická a je lepšie sa jej vyhnúť.

Nemali sme túto možnosť: zbrane založené na projektiloch boli kľúčovým prvkom dizajnu hry. Museli sme teda niečo vymyslieť. Po krátkom brainstormingu sme sformulovali dve možnosti, ktoré sa zdali fungovať:

1. Projektil naviažeme na čas hráča, ktorý ho vytvoril. Každým tiknutím simulácie servera, pre každú guľku každého hráča vrátime fyzický svet späť do stavu klienta a vykonáme potrebné výpočty. Tento prístup umožnil distribuované zaťaženie servera a predvídateľný čas letu projektilov. Predvídateľnosť bola pre nás obzvlášť dôležitá, keďže všetky projektily, vrátane nepriateľských, máme predpovedané na klientovi.

Ako sme vylepšili mechaniku balistických výpočtov pre mobilnú strieľačku pomocou algoritmu kompenzácie latencie siete
Na obrázku hráč s označením 30 odpáli raketu v očakávaní: vidí, ktorým smerom beží nepriateľ a pozná približnú rýchlosť strely. Lokálne vidí, že zasiahol cieľ na 33. tick. Vďaka kompenzácii oneskorenia sa objaví aj na serveri

2. Všetko robíme rovnako ako v prvej možnosti, ale po započítaní jedného tiknutia simulácie guľky sa nezastavíme, ale pokračujeme v simulácii letu v rámci toho istého tiknutia servera, zakaždým, keď sa čas priblíži k serveru. jeden po druhom zaškrtnite a aktualizujte polohy zrážača. Robíme to dovtedy, kým sa nestane jedna z dvoch vecí:

  • Čas použiteľnosti náboja vypršal. To znamená, že výpočty sú ukončené, môžeme počítať netrafenie alebo zásah. A to je ten istý tick, v ktorom zaznel výstrel! Pre nás to bolo plus aj mínus. Plus - pretože pre strieľajúceho hráča to výrazne znížilo oneskorenie medzi zásahom a znížením zdravia nepriateľa. Nevýhodou je, že rovnaký efekt bol pozorovaný, keď súperi vystrelili na hráča: nepriateľ, ako sa zdá, vystrelil iba pomalú raketu a poškodenie už bolo spočítané.
  • Odrážka dosiahla čas servera. V takom prípade bude jeho simulácia pokračovať pri ďalšom tikte servera bez akejkoľvek kompenzácie oneskorenia. Pri pomalých projektiloch by to teoreticky mohlo znížiť počet fyzikálnych rollbackov v porovnaní s prvou možnosťou. Súčasne sa zvýšilo nerovnomerné zaťaženie simulácie: server bol buď nečinný, alebo v jednom serverovom tiku počítal tucet simulačných tikov pre niekoľko odrážok.

Ako sme vylepšili mechaniku balistických výpočtov pre mobilnú strieľačku pomocou algoritmu kompenzácie latencie siete
Rovnaký scenár ako na predchádzajúcom obrázku, ale vypočítaný podľa druhej schémy. Raketa „dobehla“ čas servera v rovnakom čase, keď došlo k výstrelu, a zásah môže byť započítaný už pri ďalšom tiknutí. Pri 31. zaškrtnutí sa v tomto prípade kompenzácia oneskorenia už neuplatňuje

V našej implementácii sa tieto dva prístupy líšili len v niekoľkých riadkoch kódu, takže sme vytvorili oba a po dlhú dobu existovali paralelne. V závislosti od mechaniky zbrane a rýchlosti strely sme pre každého dinosaura vybrali jednu alebo druhú možnosť. Zlomovým bodom bolo objavenie sa v hre mechaniky typu „ak zasiahnete nepriateľa toľkokrát za taký a taký čas, dostanete taký a taký bonus“. Akýkoľvek mechanik, v ktorom zohrával dôležitú úlohu čas, v ktorom hráč zasiahol nepriateľa, odmietol pracovať s druhým prístupom. Nakoniec sme teda zvolili prvú možnosť, ktorá sa teraz vzťahuje na všetky zbrane a všetky aktívne schopnosti v hre.

Samostatne stojí za to nastoliť otázku výkonu. Ak ste si mysleli, že toto všetko spomalí veci, odpovedám: je. Unity je dosť pomalý v pohybe kolidérov a ich zapínaní a vypínaní. V Dino Squad, v "najhoršom prípade", môže byť v boji súčasne niekoľko stoviek projektilov. Posúvanie zrážačov na počítanie každého projektilu jednotlivo je nedostupný luxus. Preto bolo absolútne nevyhnutné, aby sme minimalizovali počet fyzikálnych „rollbackov“. K tomu sme v ECS vytvorili samostatnú zložku, do ktorej zaznamenávame hráčov čas. Pridali sme ho ku všetkým entitám, ktoré vyžadujú kompenzáciu oneskorenia (projektily, schopnosti atď.). Predtým, ako začneme spracovávať takéto entity, v tomto čase ich zhlukujeme a spracujeme spoločne, pričom fyzický svet vrátime späť raz pre každý klaster.

V tejto fáze máme všeobecne fungujúci systém. Jeho kód v trochu zjednodušenej forme:

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

Zostávalo už len nakonfigurovať detaily:

1. Pochopte, ako veľmi obmedziť maximálnu vzdialenosť pohybu v čase.

Bolo pre nás dôležité, aby bola hra čo najprístupnejšia v podmienkach slabých mobilných sietí, preto sme príbeh obmedzili na 30 tikov (s tick rate 20 Hz). To umožňuje hráčom zasiahnuť súperov aj pri veľmi vysokých pingoch.

2. Určte, ktoré predmety je možné posúvať v čase a ktoré nie.

My, samozrejme, rozhýbeme súperov. Ale napríklad inštalovateľné energetické štíty nie sú. Rozhodli sme sa, že je lepšie dať prednosť defenzívnej schopnosti, ako sa to často robí v online strieľačkách. Ak už hráč umiestnil štít v prítomnosti, nemali by ním preletieť guľky z minulosti s kompenzáciou oneskorenia.

3. Rozhodnite sa, či je potrebné kompenzovať schopnosti dinosaurov: uhryznutie, úder chvostom atď. Rozhodli sme sa, čo je potrebné, a spracovali sme ich podľa rovnakých pravidiel ako guľky.

4. Určte, čo robiť s kolidérmi hráča, pre ktorého sa vykonáva kompenzácia oneskorenia. V dobrom slova zmysle by sa ich pozícia nemala posúvať do minulosti: hráč by sa mal vidieť v rovnakom čase, v akom je teraz na serveri. Vraciame však aj kolidéry strieľajúceho hráča a má to viacero dôvodov.

Po prvé, zlepšuje klastrovanie: môžeme použiť rovnaký fyzický stav pre všetkých hráčov s blízkymi pingmi.

Po druhé, vo všetkých raycastoch a presahoch vždy vylúčime kolidéry hráča, ktorý vlastní schopnosti alebo projektily. V Dino Squad hráči ovládajú dinosaurov, ktorí majú na pomery strelcov dosť neštandardnú geometriu. Aj keď hráč strieľa v nezvyčajnom uhle a trajektória strely prejde hráčovým zrážačom dinosaurov, guľka to bude ignorovať.

Po tretie, vypočítame polohy dinosaurovej zbrane alebo miesto aplikácie schopnosti pomocou údajov z ECS ešte pred začiatkom kompenzácie oneskorenia.

Tým pádom je pre nás nepodstatná reálna pozícia kolidérov hráča s kompenzáciou lagu, preto sme sa vybrali produktívnejšou a zároveň jednoduchšou cestou.

Latencia siete sa nedá jednoducho odstrániť, dá sa iba maskovať. Ako každá iná metóda maskovania, kompenzácia oneskorenia servera má svoje nevýhody. Zlepšuje herný zážitok hráča, ktorý strieľa na úkor hráča, na ktorého sa strieľa. Pre Dino Squad však bola voľba jasná.

Samozrejme, toto všetko muselo byť zaplatené aj zvýšenou zložitosťou serverového kódu ako celku - pre programátorov aj herných dizajnérov. Ak bola simulácia skôr jednoduchým sekvenčným volaním systémov, potom s kompenzáciou oneskorenia sa v nej objavili vnorené slučky a vetvy. Vynaložili sme tiež veľa úsilia na to, aby sa s ním pohodlne pracovalo.

Vo verzii 2019 (a možno o niečo skôr) Unity pridalo plnú podporu pre nezávislé fyzické scény. Na server sme ich implementovali takmer okamžite po aktualizácii, pretože sme sa chceli rýchlo zbaviť fyzického sveta spoločného pre všetky miestnosti.

Každej herni sme dali vlastnú fyzickú scénu, čím sme eliminovali potrebu „vyčistiť“ scénu od údajov susednej miestnosti pred výpočtom simulácie. Po prvé, prinieslo to výrazné zvýšenie produktivity. Po druhé, umožnilo zbaviť sa celej triedy chýb, ktoré vznikli, ak sa programátor pri pridávaní nových herných prvkov pomýlil v kóde na čistenie scény. Takéto chyby bolo ťažké odladiť a často viedli k tomu, že stav fyzických objektov v scéne jednej miestnosti „pretekal“ do inej miestnosti.

Okrem toho sme urobili prieskum, či by sa fyzické scény dali použiť na uloženie histórie fyzického sveta. To znamená, že podmienečne prideľte nie jednu scénu každej miestnosti, ale 30 scén a vytvorte z nich cyklickú vyrovnávaciu pamäť, do ktorej uložíte príbeh. Vo všeobecnosti sa ukázalo, že táto možnosť funguje, ale nerealizovali sme ju: nevykazovala žiadne šialené zvýšenie produktivity, ale vyžadovala si pomerne riskantné zmeny. Bolo ťažké predpovedať, ako sa server zachová pri dlhej práci s toľkými scénami. Preto sme sa riadili pravidlom: „Ak nie je zlomený, neopravujte ho".

Zdroj: hab.com

Pridať komentár