Jak jsme vytvořili mechaniku balistického výpočtu pro mobilní střílečku s algoritmem kompenzace zpoždění sítě

Jak jsme vytvořili mechaniku balistického výpočtu pro mobilní střílečku s algoritmem kompenzace zpoždění sítě

Ahoj, jsem Nikita Brizhak, vývojář serverů od Pixonic. Dnes bych chtěl mluvit o kompenzaci zpoždění v mobilním multiplayeru.

O kompenzaci zpoždění serveru bylo napsáno mnoho článků, a to i v ruštině. To není překvapivé, protože tato technologie se aktivně používá při vytváření multiplayerových FPS od konce 90. Vzpomenout si můžete například na mod QuakeWorld, který jej jako jeden z prvních použil.

Používáme ji také v naší mobilní multiplayerové střílečce Dino Squad.

V tomto článku není mým cílem opakovat to, co již bylo napsáno tisíckrát, ale říci, jak jsme do naší hry implementovali kompenzaci zpoždění s ohledem na náš technologický stack a základní herní funkce.

Pár slov o naší kůře a technologii.

Dino Squad je síťová mobilní PvP střílečka. Hráči ovládají dinosaury vybavené různými zbraněmi a bojují mezi sebou v týmech 6v6.

Klient i server jsou založeny na Unity. Architektura je pro střílečky docela klasická: server je autoritářský a predikce klienta funguje na klientech. Simulace hry je napsána pomocí interního ECS a používá se na serveru i klientovi.

Pokud o kompenzaci zpoždění slyšíte poprvé, zde je krátký exkurz do problematiky.

Ve hrách FPS pro více hráčů je zápas obvykle simulován na vzdáleném serveru. Hráči pošlou svůj vstup (informace o stisknutých klávesách) na server a server jim jako odpověď zašle aktualizovaný stav hry s přihlédnutím k přijatým datům. S tímto schématem interakce bude prodleva mezi stisknutím tlačítka vpřed a okamžikem pohybu postavy hráče na obrazovce vždy větší než ping.

Zatímco na lokálních sítích může být toto zpoždění (lidově řečeno input lag) nepostřehnutelné, při hraní přes internet vytváří pocit „klouzání po ledě“ při ovládání postavy. Tento problém platí dvojnásob pro mobilní sítě, kde případ, kdy je ping hráče 200 ms, je stále považován za vynikající spojení. Často může být ping 350, 500 nebo 1000 ms. Pak je téměř nemožné hrát rychlou střílečku se input lagem.

Řešením tohoto problému je simulační predikce na straně klienta. Zde klient sám aplikuje vstup na postavu hráče, aniž by čekal na odpověď ze serveru. A když obdrží odpověď, jednoduše porovná výsledky a aktualizuje pozice oponentů. Prodleva mezi stisknutím klávesy a zobrazením výsledku na obrazovce je v tomto případě minimální.

Zde je důležité pochopit nuanci: klient vždy kreslí sám podle svého posledního vstupu a nepřátelé - se zpožděním sítě, podle předchozího stavu z dat ze serveru. To znamená, že když střílíte na nepřítele, hráč ho vidí v minulosti relativně k sobě. Více o predikci klienta psali jsme dříve.

Klientská predikce tedy řeší jeden problém, ale vytváří další: pokud hráč střílí v místě, kde byl nepřítel v minulosti, na serveru při střelbě na stejném místě, nepřítel už na tomto místě nemusí být. Tento problém se pokouší vyřešit kompenzace zpoždění serveru. Když dojde k výstřelu ze zbraně, server obnoví stav hry, který hráč v době výstřelu lokálně viděl, a zkontroluje, zda skutečně mohl zasáhnout nepřítele. Pokud je odpověď „ano“, zásah se počítá, i když nepřítel v tomto okamžiku již není na serveru.

Vyzbrojeni těmito znalostmi jsme začali implementovat kompenzaci zpoždění serveru v Dino Squad. Nejprve jsme museli pochopit, jak obnovit na serveru to, co klient viděl? A co přesně je potřeba obnovit? V naší hře se zásahy zbraní a schopností počítají prostřednictvím raycastů a překryvů – tedy prostřednictvím interakcí s fyzickými kolidéry nepřítele. V souladu s tím jsme potřebovali reprodukovat pozici těchto kolidérů, které hráč „viděl“ lokálně, na serveru. V té době jsme používali Unity verzi 2018.x. Fyzikální API tam je statické, fyzický svět existuje v jediné kopii. Neexistuje způsob, jak uložit jeho stav a poté jej obnovit z krabice. Tak co dělat?

Řešení bylo na povrchu, všechny jeho prvky jsme již použili k řešení jiných problémů:

  1. U každého klienta potřebujeme vědět, v jakém čase viděl soupeře, když stiskl klávesy. Tyto informace jsme již zapsali do vstupního balíčku a použili je k úpravě predikce klienta.
  2. Musíme být schopni ukládat historii herních situací. Právě v něm budeme držet pozice našich protivníků (a potažmo jejich narážečů). Historii stavu jsme již na serveru měli, použili jsme ji k sestavení delty. Když známe správný čas, mohli bychom snadno najít správný stav v historii.
  3. Nyní, když máme v ruce stav hry z historie, musíme být schopni synchronizovat data hráčů se stavem fyzického světa. Stávající srážeče – pohyb, chybějící – vytvoření, nepotřebné – zničení. Tato logika již byla také napsána a sestávala z několika systémů ECS. Použili jsme to k uspořádání několika herních místností v jednom procesu Unity. A protože fyzický svět je jeden na proces, musel být znovu použit mezi místnostmi. Před každým tiknutím simulace jsme „resetovali“ stav fyzického světa a znovu jej inicializovali s daty pro aktuální místnost, přičemž jsme se prostřednictvím chytrého systému sdružování snažili co nejvíce znovu využít herní objekty Unity. Nezbývalo než vyvolat stejnou logiku pro herní stav z minulosti.

Spojením všech těchto prvků dohromady jsme získali „stroj času“, který dokázal vrátit stav fyzického světa do správného okamžiku. Kód se ukázal být 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;
     }
}

Zbývalo jen vymyslet, jak tento stroj využít ke snadné kompenzaci střel a schopností.

V nejjednodušším případě, kdy je mechanika založena na jediném hitscanu, se zdá být vše jasné: než hráč vystřelí, musí vrátit fyzický svět zpět do požadovaného stavu, provést raycast, spočítat zásah nebo minout a vrátit svět do výchozího stavu.

Ale takových mechanik je v Dino Squad velmi málo! Většina zbraní ve hře vytváří projektily – kulky s dlouhou životností, které létají na několik simulačních tiků (v některých případech i desítky tiků). Co s nimi dělat, v kolik hodin by měli letět?

В starověký článek ohledně Half-Life network stacku se hoši z Valve ptali na stejnou otázku a jejich odpověď byla tato: kompenzace zpoždění projektilu je problematická a je lepší se jí vyhnout.

Tuto možnost jsme neměli: střelné zbraně byly klíčovým prvkem designu hry. Museli jsme tedy něco vymyslet. Po krátkém brainstormingu jsme formulovali dvě možnosti, které se zdály fungovat:

1. Projektil navážeme na čas hráče, který jej vytvořil. Každým tiknutím simulace serveru, pro každou kulku každého hráče vrátíme fyzický svět zpět do stavu klienta a provedeme potřebné výpočty. Tento přístup umožnil distribuovanou zátěž na serveru a předvídatelnou dobu letu projektilů. Předvídatelnost byla pro nás obzvláště důležitá, protože všechny projektily, včetně nepřátelských, máme předpověděné na klientovi.

Jak jsme vytvořili mechaniku balistického výpočtu pro mobilní střílečku s algoritmem kompenzace zpoždění sítě
Na obrázku hráč s tick 30 vystřelí raketu v očekávání: vidí, kterým směrem nepřítel běží a zná přibližnou rychlost střely. Místně vidí, že zasáhl cíl ve 33. tiknutí. Díky kompenzaci zpoždění se objeví i na serveru

2. Vše děláme stejně jako v první možnosti, ale po započítání jednoho tiknutí simulace kulky se nezastavíme, ale pokračujeme v simulaci jejího letu v rámci stejného ticku serveru, pokaždé, když se její čas přiblíží k serveru. jeden po druhém zaškrtněte a aktualizujte pozice srážeče. Děláme to, dokud se nestane jedna ze dvou věcí:

  • Střela vypršela. To znamená, že výpočty jsou u konce, můžeme počítat minutí nebo zásah. A to je na stejném tikotu, ve kterém došlo k výstřelu! Pro nás to bylo plus i mínus. Plus - protože pro střílejícího hráče to výrazně zkrátilo zpoždění mezi zásahem a snížením zdraví nepřítele. Nevýhodou je, že stejný efekt byl pozorován, když protivníci stříleli na hráče: zdálo se, že nepřítel vypálil pouze pomalou raketu a poškození již bylo započítáno.
  • Odrážka dosáhla času serveru. V tomto případě bude jeho simulace pokračovat v dalším taktu serveru bez jakékoli kompenzace zpoždění. U pomalých střel by to teoreticky mohlo snížit počet fyzikálních rollbacků ve srovnání s první možností. Současně se zvýšilo nerovnoměrné zatížení simulace: server byl buď nečinný, nebo v jednom ticku serveru počítal tucet tiků simulace pro několik odrážek.

Jak jsme vytvořili mechaniku balistického výpočtu pro mobilní střílečku s algoritmem kompenzace zpoždění sítě
Stejný scénář jako na předchozím obrázku, ale vypočtený podle druhého schématu. Střela „dohnala“ čas serveru ve stejnou chvíli, kdy došlo k výstřelu, a zásah může být započítán již při dalším tiknutí. Při 31. zatržení se v tomto případě kompenzace zpoždění již neuplatňuje

V naší implementaci se tyto dva přístupy lišily jen v několika řádcích kódu, takže jsme vytvořili oba a po dlouhou dobu existovaly paralelně. V závislosti na mechanice zbraně a rychlosti střely jsme pro každého dinosaura zvolili tu či onu možnost. Zlomovým bodem bylo, že se ve hře objevily mechaniky typu „když zasáhneš nepřítele tolikrát za takovou a takovou dobu, získej takový a takový bonus“. Jakýkoli mechanik, kde hrál důležitou roli čas, kdy hráč zasáhl nepřítele, odmítl pracovat s druhým přístupem. Nakonec jsme tedy zvolili první možnost, která se nyní vztahuje na všechny zbraně a všechny aktivní schopnosti ve hře.

Samostatně stojí za to vznést otázku výkonu. Pokud jste si mysleli, že to vše zpomalí, odpovídám: je. Unity je poměrně pomalý v pohybu srážečů a jejich zapínání a vypínání. V Dino Squad může v nejhorším případě existovat několik stovek projektilů současně v boji. Posouvání urychlovačů pro počítání každého projektilu jednotlivě je nedostupný luxus. Proto bylo naprosto nezbytné, abychom minimalizovali počet fyzikálních „rollbacků“. K tomu jsme v ECS vytvořili samostatnou komponentu, do které zaznamenáváme čas hráče. Přidali jsme ji ke všem entitám, které vyžadují kompenzaci zpoždění (projektily, schopnosti atd.). Než takové entity začneme zpracovávat, do této doby je shlukneme a zpracujeme společně, přičemž fyzický svět vrátíme zpět pro každý shluk jednou.

V této fázi máme obecně fungující systém. Jeho kód v poněkud zjednodušené podobě:

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

Zbývalo pouze nakonfigurovat podrobnosti:

1. Pochopit, jak moc omezit maximální vzdálenost pohybu v čase.

Bylo pro nás důležité, aby byla hra co nejpřístupnější v podmínkách špatných mobilních sítí, proto jsme příběh omezili na 30 tiků (s frekvencí tikání 20 Hz). To umožňuje hráčům zasáhnout soupeře i při velmi vysokých pingech.

2. Určete, které předměty lze v čase přesunout a které ne.

My samozřejmě rozpohybujeme soupeře. Ale například instalovatelné energetické štíty nejsou. Rozhodli jsme se, že je lepší dát přednost defenzivní schopnosti, jak se to často dělá v online střílečkách. Pokud již hráč umístil štít do přítomnosti, neměly by jím proletět kulky z minulosti s kompenzací zpoždění.

3. Rozhodněte, zda je nutné kompenzovat schopnosti dinosaurů: kousnutí, úder ocasem atd. Rozhodli jsme se, co je potřeba, a zpracovali je podle stejných pravidel jako kulky.

4. Určete, co dělat s kolidéry hráče, u kterého se provádí kompenzace zpoždění. V dobrém slova smyslu by se jejich pozice neměla posouvat do minulosti: hráč by se měl vidět ve stejnou dobu, ve které je nyní na serveru. Odvracíme však také srážeče střílejícího hráče a má to několik důvodů.

Za prvé, zlepšuje shlukování: můžeme použít stejný fyzický stav pro všechny hráče s blízkými pingy.

Za druhé, ve všech raycastech a přesahech vždy vyloučíme srážeče hráče, který vlastní schopnosti nebo projektily. V Dino Squad hráči ovládají dinosaury, kteří mají na poměry střelců dost nestandardní geometrii. I když hráč vystřelí pod neobvyklým úhlem a trajektorie kulky projde hráčovým srážečem dinosaurů, kulka to bude ignorovat.

Zatřetí vypočítáme pozice dinosauří zbraně nebo místo aplikace schopnosti pomocí dat z ECS ještě před začátkem kompenzace zpoždění.

Ve výsledku je pro nás skutečná pozice kolidérů hráče s kompenzací lagu nedůležitá, proto jsme se vydali produktivnější a zároveň jednodušší cestou.

Latenci sítě nelze jednoduše odstranit, lze ji pouze maskovat. Jako každý jiný způsob maskování má kompenzace zpoždění serveru své nevýhody. Zlepšuje herní zážitek hráče, který střílí na úkor hráče, na kterého se střílí. Pro Dino Squad však byla volba jasná.

To vše muselo být samozřejmě také zaplaceno zvýšenou složitostí kódu serveru jako celku - jak pro programátory, tak pro herní designéry. Jestliže dříve byla simulace jednoduchým sekvenčním voláním systémů, pak se v ní s kompenzací zpoždění objevily vnořené smyčky a větve. Vynaložili jsme také mnoho úsilí, aby se s ním pohodlně pracovalo.

Ve verzi 2019 (a možná o něco dříve) Unity přidalo plnou podporu nezávislých fyzických scén. Implementovali jsme je na server téměř okamžitě po aktualizaci, protože jsme se chtěli rychle zbavit fyzického světa společného všem místnostem.

Každé herně jsme dali vlastní fyzickou scénu a tím jsme eliminovali potřebu „vyčišťovat“ scénu od dat sousední místnosti před výpočtem simulace. Za prvé přineslo výrazné zvýšení produktivity. Zadruhé to umožnilo zbavit se celé třídy chyb, které vznikly, pokud programátor udělal chybu v kódu pro vyčištění scény při přidávání nových herních prvků. Takové chyby bylo obtížné odladit a často vedly k tomu, že stav fyzických objektů ve scéně jedné místnosti „přetékal“ do jiné místnosti.

Kromě toho jsme provedli nějaký výzkum, zda lze fyzické scény použít k uložení historie fyzického světa. To znamená, že každé místnosti přidělte ne jednu scénu, ale 30 scén a vytvořte z nich cyklický buffer, do kterého se příběh uloží. Obecně se ukázalo, že tato možnost funguje, ale nerealizovali jsme ji: nevykazovala žádné šílené zvýšení produktivity, ale vyžadovala poměrně riskantní změny. Bylo těžké předvídat, jak se server zachová při dlouhodobé práci s tolika scénami. Proto jsme se řídili pravidlem: „Pokud se nerozbije, neopravujte".

Zdroj: www.habr.com

Přidat komentář