Kako smo poboljšali mehaniku balističkih izračuna za mobilnog strijelca s algoritmom za kompenzaciju latencije mreže

Kako smo poboljšali mehaniku balističkih izračuna za mobilnog strijelca s algoritmom za kompenzaciju latencije mreže

Bok, ja sam Nikita Brizhak, programer poslužitelja iz Pixonica. Danas bih želio govoriti o kompenzaciji kašnjenja u mobilnom multiplayeru.

Mnogo je članaka napisano o kompenzaciji kašnjenja poslužitelja, uključujući i na ruskom. To ne čudi, budući da se ova tehnologija aktivno koristi u stvaranju FPS-a za više igrača od kasnih 90-ih. Na primjer, možete se sjetiti moda QuakeWorld, koji je bio jedan od prvih koji ga je koristio.

Također ga koristimo u našoj mobilnoj pucačini za više igrača Dino Squad.

U ovom članku, moj cilj nije ponoviti ono što je već napisano tisuću puta, već reći kako smo implementirali kompenzaciju kašnjenja u našoj igri, uzimajući u obzir naš tehnološki skup i osnovne značajke igranja.

Nekoliko riječi o našem korteksu i tehnologiji.

Dino Squad je mrežna mobilna PvP pucačina. Igrači kontroliraju dinosaure opremljene raznim oružjem i međusobno se bore u timovima 6 na 6.

I klijent i poslužitelj temelje se na Unityju. Arhitektura je sasvim klasična za strijelce: server je autoritaran, a predviđanje klijenta radi na klijentima. Simulacija igre napisana je pomoću internog ECS-a i koristi se i na poslužitelju i na klijentu.

Ako prvi put čujete za kompenzaciju kašnjenja, evo kratkog izleta u to pitanje.

U FPS igrama za više igrača meč se obično simulira na udaljenom poslužitelju. Igrači šalju svoj unos (informacije o pritisnutim tipkama) poslužitelju, a kao odgovor poslužitelj im šalje ažurirano stanje igre uzimajući u obzir primljene podatke. S ovom shemom interakcije, kašnjenje između pritiska tipke naprijed i trenutka kada se lik igrača pomakne na zaslonu uvijek će biti veće od pinga.

Dok na lokalnim mrežama ovo kašnjenje (popularno zvano input lag) može biti neprimjetno, kod igranja putem interneta stvara se osjećaj "klizanja po ledu" pri upravljanju likom. Ovaj problem je dvostruko relevantan za mobilne mreže, gdje se slučaj kada igračev ping iznosi 200 ms još uvijek smatra izvrsnom vezom. Često ping može biti 350, 500 ili 1000 ms. Tada postaje gotovo nemoguće igrati brzu pucačinu s input lagom.

Rješenje ovog problema je predviđanje simulacije na strani klijenta. Ovdje sam klijent primjenjuje unos na karakter igrača, bez čekanja odgovora od poslužitelja. A kada se primi odgovor, jednostavno se uspoređuju rezultati i ažuriraju pozicije protivnika. Odgoda između pritiska tipke i prikaza rezultata na zaslonu u ovom je slučaju minimalna.

Ovdje je važno razumjeti nijansu: klijent se uvijek crta prema svom posljednjem unosu, a neprijatelji - s mrežnim kašnjenjem, prema prethodnom stanju iz podataka s poslužitelja. Odnosno, kada puca na neprijatelja, igrač ga vidi u prošlosti u odnosu na sebe. Više o predviđanju klijenata pisali smo ranije.

Dakle, predviđanje klijenta rješava jedan problem, ali stvara drugi: ako igrač puca na točku gdje je neprijatelj bio u prošlosti, na serveru kada puca na istu točku, neprijatelj možda više nije na tom mjestu. Kompenzacija kašnjenja poslužitelja pokušava riješiti ovaj problem. Kada se puca iz oružja, poslužitelj vraća stanje igre koje je igrač vidio lokalno u trenutku pucanja i provjerava je li doista mogao pogoditi neprijatelja. Ako je odgovor "da", pogodak se računa, čak i ako neprijatelj više nije na serveru u tom trenutku.

Naoružani ovim znanjem, počeli smo implementirati kompenzaciju kašnjenja poslužitelja u Dino Squadu. Prije svega, morali smo razumjeti kako vratiti na poslužitelj ono što je klijent vidio? A što točno treba obnoviti? U našoj igri, pogoci oružja i sposobnosti izračunavaju se putem zračenja i preklapanja - to jest, kroz interakcije s neprijateljskim fizičkim sudaračima. U skladu s tim, morali smo reproducirati položaj tih sudarača, koje je igrač “vidio” lokalno, na poslužitelju. U to vrijeme koristili smo Unity verziju 2018.x. Fizički API tamo je statičan, fizički svijet postoji u jednom primjerku. Ne postoji način da se spremi njegovo stanje i zatim vrati iz kutije. Pa što učiniti?

Rješenje je bilo na površini, svi njegovi elementi već su korišteni za rješavanje drugih problema:

  1. Za svakog klijenta moramo znati u koje vrijeme je vidio protivnike kada je pritisnuo tipke. Već smo upisali ove informacije u ulazni paket i upotrijebili ih za prilagodbu predviđanja klijenta.
  2. Moramo biti u mogućnosti pohraniti povijest stanja igre. Upravo u njemu ćemo držati položaje naših protivnika (a time i njihovih sudarača). Već smo imali povijest stanja na poslužitelju, koristili smo je za izgradnju delte. Poznavajući pravo vrijeme, mogli bismo lako pronaći pravo stanje u povijesti.
  3. Sada kada imamo stanje igre iz povijesti u ruci, moramo biti u mogućnosti sinkronizirati podatke igrača sa stanjem fizičkog svijeta. Postojeći sudarači - premjestite, nedostajuće - stvorite, nepotrebne - uništite. Ova je logika također već bila napisana i sastojala se od nekoliko ECS sustava. Koristili smo ga za držanje nekoliko soba za igru ​​u jednom Unity procesu. A budući da je fizički svijet jedan po procesu, morao se ponovno koristiti između soba. Prije svakog otkucaja simulacije, "resetirali" smo stanje fizičkog svijeta i ponovno ga inicijalizirali s podacima za trenutnu sobu, pokušavajući ponovno koristiti objekte Unity igre što je više moguće kroz pametan sustav udruživanja. Ostalo je samo pozvati se na istu logiku za stanje igre iz prošlosti.

Spajajući sve te elemente zajedno, dobili smo "vremenski stroj" koji je mogao vratiti stanje fizičkog svijeta u pravi trenutak. Kod se pokazao jednostavnim:

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

Ostalo je samo smisliti kako pomoću ovog stroja lako kompenzirati udarce i sposobnosti.

U najjednostavnijem slučaju, kada se mehanika temelji na jednom hitscanu, čini se da je sve jasno: prije nego što igrač puca, mora vratiti fizički svijet u željeno stanje, napraviti raycast, prebrojati pogodak ili promašaj i vratiti svijet u početno stanje.

Ali takvih je mehaničara u Dino Squadu jako malo! Većina oružja u igri stvara projektile - dugovječne metke koji lete nekoliko simulacijskih otkucaja (u nekim slučajevima, desetke odsječaka). Što učiniti s njima, u koje vrijeme trebaju letjeti?

В starinski članak Što se tiče Half-Life mrežnog skupa, dečki iz Valvea postavili su isto pitanje, a njihov odgovor je bio sljedeći: kompenzacija kašnjenja projektila je problematična i bolje ju je izbjegavati.

Nismo imali ovu opciju: oružje temeljeno na projektilima bilo je ključno obilježje dizajna igre. Pa smo morali nešto smisliti. Nakon kratkog razmišljanja formulirali smo dvije opcije za koje se činilo da funkcioniraju:

1. Projektil vežemo za vrijeme igrača koji ga je kreirao. Svakim tikom simulacije poslužitelja, za svaki metak svakog igrača, vraćamo fizički svijet u stanje klijenta i izvodimo potrebne izračune. Ovakav pristup omogućio je raspodijeljeno opterećenje na poslužitelju i predvidljivo vrijeme leta projektila. Posebno nam je bila važna predvidljivost, budući da imamo sve projektile, uključujući i neprijateljske, predviđene na klijentu.

Kako smo poboljšali mehaniku balističkih izračuna za mobilnog strijelca s algoritmom za kompenzaciju latencije mreže
Na slici igrač na poziciji 30 ispaljuje projektil u očekivanju: on vidi u kojem smjeru trči neprijatelj i zna približnu brzinu projektila. Lokalno vidi da je pogodio metu na 33. otkucaju. Zahvaljujući kompenzaciji kašnjenja, pojavit će se i na poslužitelju

2. Radimo sve isto kao u prvoj opciji, ali, nakon što smo prebrojali jedan tick simulacije metka, ne zaustavljamo se, već nastavljamo simulirati njegov let unutar istog tika poslužitelja, svaki put približavajući svoje vrijeme poslužitelju jednu po jednu kvačicu i ažuriranje položaja sudarača. To radimo dok se ne dogodi jedna od dvije stvari:

  • Metak je istekao. To znači da su kalkulacije gotove, možemo računati promašaj ili pogodak. I to u istom trenutku u kojem je pucano! Za nas je to bio i plus i minus. Plus - jer je za igrača koji puca to značajno smanjilo kašnjenje između pogotka i smanjenja zdravlja neprijatelja. Loša strana je što je isti učinak primijećen kada su protivnici pucali na igrača: neprijatelj je, čini se, ispalio samo sporu raketu, a šteta je već izbrojana.
  • Metak je dosegao vrijeme poslužitelja. U ovom slučaju, njegova će se simulacija nastaviti u sljedećem koraku poslužitelja bez ikakve kompenzacije kašnjenja. Za spore projektile to bi teoretski moglo smanjiti broj vraćanja fizike u odnosu na prvu opciju. Istodobno se povećalo neravnomjerno opterećenje simulacije: poslužitelj je ili bio u stanju mirovanja ili je u jednom poslužiteljskom tiku izračunavao desetak simulacijskih tikova za nekoliko metaka.

Kako smo poboljšali mehaniku balističkih izračuna za mobilnog strijelca s algoritmom za kompenzaciju latencije mreže
Isti scenarij kao na prethodnoj slici, ali izračunat prema drugoj shemi. Projektil je "sustigao" vrijeme poslužitelja u istom trenutku kad je došlo do pucanja, a pogodak se može računati već od sljedećeg broja. Na 31. kvačici, u ovom slučaju, kompenzacija kašnjenja se više ne primjenjuje

U našoj implementaciji ta su se dva pristupa razlikovala u samo par linija koda, pa smo kreirali oba i dugo su egzistirali paralelno. Ovisno o mehanici oružja i brzini metka, za svakog smo dinosaura odabrali jednu ili drugu opciju. Prekretnica je bila pojava u igri mehanike poput "ako pogodite neprijatelja toliko puta u tom i tom vremenu, dobit ćete taj i taj bonus." Svaka mehanika kod koje je vrijeme u kojem je igrač udario neprijatelja igralo važnu ulogu odbila je raditi s drugim pristupom. Tako smo na kraju odabrali prvu opciju, a ona se sada odnosi na sva oružja i sve aktivne sposobnosti u igri.

Zasebno je vrijedno pokrenuti pitanje izvedbe. Ako ste mislili da će sve ovo usporiti stvari, odgovaram: jeste. Unity je prilično spor u pomicanju sudarača i njihovom paljenju i gašenju. U Dino Squadu, u "najgorem" slučaju, može postojati nekoliko stotina projektila istovremeno u borbi. Premještanje kolajdera za brojanje svakog projektila pojedinačno luksuz je nedopustiv. Stoga nam je bilo apsolutno neophodno minimizirati broj fizikalnih "povrataka". Da bismo to učinili, napravili smo zasebnu komponentu u ECS-u u kojoj bilježimo vrijeme igrača. Dodali smo ga svim entitetima koji zahtijevaju kompenzaciju kašnjenja (projektili, sposobnosti itd.). Prije nego počnemo obrađivati ​​takve entitete, grupiramo ih do tog trenutka i obrađujemo ih zajedno, vraćajući fizički svijet jednom za svaki klaster.

U ovoj fazi imamo općenito funkcionirajući sustav. Njegov kod u nešto pojednostavljenom obliku:

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

Ostalo je samo konfigurirati detalje:

1. Razumjeti koliko ograničiti maksimalnu udaljenost kretanja u vremenu.

Bilo nam je važno učiniti igricu što dostupnijom u uvjetima loših mobilnih mreža pa smo priču ograničili s marginom od 30 tikova (s tick rateom od 20 Hz). To omogućuje igračima da pogađaju protivnike čak i pri vrlo visokim pingovima.

2. Odredite koji se predmeti mogu pomicati u vremenu, a koji ne.

Mi, naravno, mičemo protivnike. Ali energetski štitovi koji se mogu ugraditi, na primjer, nisu. Odlučili smo da je bolje dati prednost obrambenoj sposobnosti, kao što se često radi u online pucačinama. Ako je igrač već postavio štit u sadašnjosti, meci iz prošlosti s kompenzacijom kašnjenja ne bi trebali proletjeti kroz njega.

3. Odlučite je li potrebno kompenzirati sposobnosti dinosaura: ugriz, udarac repom itd. Odlučili smo što je potrebno i obradili ih prema istim pravilima kao i metke.

4. Odredite što učiniti s kolajderima igrača za kojeg se izvodi kompenzacija zaostatka. U dobrom smislu, njihov položaj ne bi trebao prelaziti u prošlost: igrač bi trebao vidjeti sebe u istom vremenu u kojem je sada na serveru. Međutim, također vraćamo sudarače igrača koji puca, a postoji nekoliko razloga za to.

Prvo, poboljšava grupiranje: možemo koristiti isto fizičko stanje za sve igrače s bliskim pingovima.

Drugo, u svim raycastovima i preklapanjima uvijek isključujemo sudarače igrača koji posjeduje sposobnosti ili projektile. U Dino Squadu igrači kontroliraju dinosaure koji imaju prilično nestandardnu ​​geometriju prema standardima strijelaca. Čak i ako igrač puca pod neobičnim kutom i putanja metka prolazi kroz igračev sudarač dinosaura, metak će to ignorirati.

Treće, izračunavamo položaje dinosaurovog oružja ili točku primjene sposobnosti pomoću podataka iz ECS-a čak i prije početka kompenzacije kašnjenja.

Kao rezultat toga, stvarna pozicija sudarača igrača s kompenziranim kašnjenjem za nas je nevažna, pa smo krenuli produktivnijim, au isto vrijeme i jednostavnijim putem.

Kašnjenje mreže ne može se jednostavno ukloniti, može se samo maskirati. Kao i svaka druga metoda maskiranja, kompenzacija kašnjenja poslužitelja ima svoje nedostatke. Poboljšava iskustvo igranja igrača koji puca na račun igrača na kojeg se puca. Za Dino Squad, međutim, izbor je ovdje bio očit.

Naravno, sve se to također moralo platiti povećanom složenošću poslužiteljskog koda u cjelini - kako za programere tako i za dizajnere igara. Ako je ranije simulacija bila jednostavan sekvencijalni poziv sustava, tada su se uz kompenzaciju kašnjenja u njoj pojavile ugniježđene petlje i grane. Također smo uložili mnogo truda da ga učinimo praktičnim za rad.

U verziji za 2019. (a možda i malo ranije), Unity je dodao punu podršku za neovisne fizičke scene. Implementirali smo ih na poslužitelj gotovo odmah nakon ažuriranja, jer smo se htjeli brzo riješiti fizičkog svijeta zajedničkog svim sobama.

Svakoj sobi za igru ​​dali smo vlastitu fizičku scenu i time uklonili potrebu za "čišćenjem" scene od podataka susjedne sobe prije izračuna simulacije. Prvo, to je značajno povećalo produktivnost. Drugo, omogućio je da se riješi cijele klase grešaka koje su se pojavile ako je programer napravio pogrešku u kodu za čišćenje scene prilikom dodavanja novih elemenata igre. Takve pogreške bilo je teško otkloniti, a često su rezultirale stanjem fizičkih objekata u sceni jedne sobe "prelijevanjem" u drugu sobu.

Osim toga, malo smo istražili mogu li se fizički prizori koristiti za pohranjivanje povijesti fizičkog svijeta. Odnosno, uvjetno, svakoj sobi dodijelite ne jednu scenu, već 30 scena i od njih napravite ciklički međuspremnik u koji ćete pohraniti priču. Općenito, pokazalo se da opcija radi, ali je nismo implementirali: nije pokazala nikakvo ludo povećanje produktivnosti, ali je zahtijevala prilično rizične promjene. Bilo je teško predvidjeti kako će se server ponašati kada dugo radi s toliko scena. Stoga smo slijedili pravilo: “Ako nije pukao, nemojte ga popravljati".

Izvor: www.habr.com

Dodajte komentar