Kako smo napravili mehaniku balističkog proračuna za mobilni strijelac s algoritmom kompenzacije mrežnog kašnjenja

Kako smo napravili mehaniku balističkog proračuna za mobilni strijelac s algoritmom kompenzacije mrežnog kašnjenja

Zdravo, ja sam Nikita Brizhak, programer servera iz Pixonica. Danas bih želio govoriti o kompenzaciji zaostajanja u mobilnom multiplayeru.

O kompenzaciji kašnjenja servera napisano je mnogo članaka, uključujući i na ruskom. To nije iznenađujuće, jer se ova tehnologija aktivno koristi u kreiranju multiplayer FPS-a od kasnih 90-ih. Na primjer, možete se sjetiti QuakeWorld moda, 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 da ponavljam ono što je već napisano hiljadu puta, već da ispričam kako smo implementirali kompenzaciju zaostajanja u našoj igri, uzimajući u obzir našu tehnologiju i osnovne karakteristike igranja.

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

Dino Squad je mrežna mobilna PvP pucačina. Igrači kontroliraju dinosauruse opremljene raznim oružjem i bore se jedni protiv drugih u timovima 6 na 6.

I klijent i server su bazirani na Unityju. Arhitektura je prilično klasična za pucače: server je autoritaran, a predviđanje klijenata radi na klijentima. Simulacija igre je napisana pomoću internog ECS-a i koristi se i na serveru i na klijentu.

Ako je ovo prvi put da čujete za kompenzaciju zaostajanja, evo kratkog izleta u ovu temu.

U FPS igrama za više igrača, meč se obično simulira na udaljenom serveru. Igrači šalju svoj unos (informacije o pritisnutim tasterima) serveru, a kao odgovor server im šalje ažurirano stanje igre uzimajući u obzir primljene podatke. Sa ovom šemom interakcije, kašnjenje između pritiskanja tipke naprijed i trenutka kada se lik igrača kreće po ekranu uvijek će biti veće od pinga.

Dok na lokalnim mrežama ovo kašnjenje (popularno nazvano input lag) može biti neprimjetno, pri igranju putem interneta stvara osjećaj „klizanja po ledu“ prilikom upravljanja likom. Ovaj problem je dvostruko relevantan za mobilne mreže, gdje se slučaj kada je ping igrača 200 ms i dalje smatra odličnom vezom. Često ping može biti 350, 500 ili 1000 ms. Tada postaje gotovo nemoguće igrati brzog šutera sa ulaznim kašnjenjem.

Rješenje ovog problema je predviđanje simulacije na strani klijenta. Ovdje sam klijent primjenjuje ulaz na karakter igrača, ne čekajući odgovor od servera. A kada se dobije odgovor, on jednostavno upoređuje rezultate i ažurira pozicije protivnika. Kašnjenje između pritiska na tipku i prikazivanja rezultata na ekranu u ovom slučaju je minimalno.

Ovdje je važno razumjeti nijansu: klijent uvijek crta sebe prema svom posljednjem unosu, a neprijatelji - sa mrežnim kašnjenjem, prema prethodnom stanju iz podataka sa servera. 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 tačku gdje je neprijatelj bio u prošlosti, na serveru kada je pucao u istoj tački, neprijatelj možda više nije na tom mjestu. Kompenzacija kašnjenja servera pokušava da reši ovaj problem. Kada se puca iz oružja, server vraća stanje igre koje je igrač vidio lokalno u trenutku pucanja i provjerava da li je zaista 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 da implementiramo kompenzaciju zaostajanja servera u Dino Squadu. Prije svega, morali smo razumjeti kako vratiti na server ono što je klijent vidio? I šta tačno treba restaurirati? U našoj igri, pogoci iz oružja i sposobnosti se računaju putem raycast-a i preklapanja - to jest, kroz interakciju s neprijateljskim fizičkim sudaračima. Shodno tome, morali smo da reproduciramo poziciju ovih sudarača, koje je igrač „video“ lokalno, na serveru. U to vrijeme koristili smo Unity verziju 2018.x. API za fiziku je statičan, fizički svijet postoji u jednoj kopiji. Ne postoji način da sačuvate njegovo stanje i vratite ga iz kutije. Pa šta da radimo?

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

  1. Za svakog klijenta treba da znamo u koje vreme je video protivnike kada je pritiskao tastere. Ovu informaciju smo već zapisali u ulazni paket i koristili je za prilagođavanje predviđanja klijenta.
  2. Moramo biti u mogućnosti pohraniti historiju stanja igre. U njemu ćemo zadržati pozicije naših protivnika (a samim tim i njihovih sudara). Već smo imali istoriju stanja na serveru, koristili smo je za izgradnju delte. Znajući pravo vrijeme, lako bismo mogli pronaći pravo stanje u istoriji.
  3. Sada kada imamo stanje igre iz istorije u ruci, moramo biti u mogućnosti da sinkroniziramo podatke igrača sa stanjem fizičkog svijeta. Postojeći sudarači - pomjerajte, nedostajući - stvarajte, nepotrebni - uništavajte. Ova logika je takođe već bila napisana i sastojala se od nekoliko ECS sistema. Koristili smo ga za održavanje nekoliko soba za igre u jednom Unity procesu. A budući da je fizički svijet jedan po procesu, morao se ponovo koristiti između prostorija. Prije svakog otkucaja simulacije, "resetirali" smo stanje fizičkog svijeta i ponovo ga inicijalizirali podacima za trenutnu sobu, pokušavajući ponovo iskoristiti Unity objekte igre što je više moguće kroz pametan sistem udruživanja. Ostalo je samo da se pozovemo na istu logiku za stanje igre iz prošlosti.

Spajanjem svih ovih elemenata dobili smo „vremensku mašinu“ koja je mogla da vrati stanje fizičkog sveta u pravi trenutak. Ispostavilo se da je kod jednostavan:

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 koristiti ovu mašinu za laku kompenzaciju udaraca i sposobnosti.

U najjednostavnijem slučaju, kada je mehanika zasnovana na jednom skeniranju pogodaka, čini se da je sve jasno: prije nego što igrač puca, treba vratiti fizički svijet u željeno stanje, izvršiti raycast, izbrojati pogodak ili promašaj i vratiti svijet u početno stanje.

Ali takvih mehaničara je vrlo malo u Dino odredu! Većina oružja u igri stvara projektile - dugovječne metke koji lete za nekoliko simulacija (u nekim slučajevima, desetke krpelja). Šta sa njima, u koje vreme treba da lete?

В drevni članak o Half-Life mrežnom stacku, momci iz Valvea su postavili isto pitanje, a njihov odgovor je bio sljedeći: kompenzacija zaostajanja projektila je problematična i bolje je izbjegavati.

Nismo imali ovu opciju: oružje bazirano na projektilima bilo je ključna karakteristika dizajna igre. Tako da smo morali nešto smisliti. Nakon kratkog razmišljanja, formulirali smo dvije opcije koje su djelovale:

1. Projektil vežemo za vrijeme igrača koji ga je kreirao. Svaki tik simulacije servera, za svaki metak svakog igrača, vraćamo fizički svijet u stanje klijenta i vršimo potrebne proračune. Ovaj pristup je omogućio raspodijeljeno opterećenje na serveru i predvidljivo vrijeme leta projektila. Predvidljivost nam je bila posebno važna, jer imamo sve projektile, uključujući i neprijateljske, predviđene na klijentu.

Kako smo napravili mehaniku balističkog proračuna za mobilni strijelac s algoritmom kompenzacije mrežnog kašnjenja
Na slici, igrač na tački 30 ispaljuje projektil u iščekivanju: vidi u kom smjeru neprijatelj trči i zna približnu brzinu projektila. Lokalno vidi da je pogodio metu na 33. tiku. Zahvaljujući kompenzaciji zaostajanja, pojavit će se i na serveru

2. Radimo sve isto kao i u prvoj opciji, ali, izbrojavši jedan tik simulacije metka, ne prestajemo, već nastavljamo simulirati njegov let unutar istog tiketa servera, svaki put približavajući njegovo vrijeme serveru jedan po jedan tik i ažuriranje pozicija sudarača. To radimo dok se ne dogodi jedna od dvije stvari:

  • Metak je istekao. To znači da su proračuni gotovi, možemo računati promašaj ili pogodak. I to u istom trenutku u koji je ispaljen hitac! 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 u tome što je isti efekat primijećen kada su protivnici pucali na igrača: neprijatelj je, čini se, ispalio samo sporu raketu, a šteta je već izbrojana.
  • Metak je stigao do vremena servera. U ovom slučaju, njegova simulacija će se nastaviti u sljedećem serverskom tiku bez ikakve kompenzacije zaostajanja. Za spore projektile, ovo bi teoretski moglo smanjiti broj povrata fizike u odnosu na prvu opciju. Istovremeno se povećalo neravnomjerno opterećenje simulacije: server je ili bio u stanju mirovanja, ili je u jednom serverskom tiku računao desetak simulacijskih tikova za nekoliko metaka.

Kako smo napravili mehaniku balističkog proračuna za mobilni strijelac s algoritmom kompenzacije mrežnog kašnjenja
Isti scenario kao na prethodnoj slici, ali izračunat prema drugoj shemi. Projektil je "sustigao" vrijeme servera u istom tiku kada je i pucano, a pogodak se može računati već u sljedećem tiku. Na 31. tik, u ovom slučaju, kompenzacija zaostajanja se više ne primjenjuje

U našoj implementaciji ova dva pristupa su se razlikovala u samo par linija koda, pa smo kreirali oba i dugo su postojali paralelno. Ovisno o mehanici oružja i brzini metka, birali smo jednu ili drugu opciju za svakog dinosaura. Ovdje je prekretnica bila pojava u igri mehaničara tipa „ako pogodiš neprijatelja toliko puta u takvom i takvom vremenu, dobij takav i takav bonus“. Bilo koji mehaničar u kojem je vrijeme kada je igrač pogodio neprijatelja igrao važnu ulogu odbijao je raditi s drugim pristupom. Tako smo završili s prvom opcijom, a ona se sada odnosi na sva oružja i sve aktivne sposobnosti u igri.

Odvojeno, vrijedi pokrenuti pitanje performansi. Ako ste mislili da će sve ovo usporiti, odgovaram: jeste. Unity je prilično spor u pomicanju sudarača i njihovom uključivanju i isključivanju. U Dino odredu, u "najgorem" slučaju, može postojati nekoliko stotina projektila koji istovremeno postoje u borbi. Pomicanje sudarača za brojanje svakog projektila pojedinačno je nedopustiv luksuz. Stoga nam je bilo apsolutno neophodno da minimiziramo broj fizičkih „povrataka“. Da bismo to učinili, kreirali smo zasebnu komponentu u ECS-u u kojoj snimamo vrijeme igrača. Dodali smo ga svim entitetima koji zahtijevaju kompenzaciju zaostajanja (projektili, sposobnosti itd.). Prije nego počnemo obraditi takve entitete, do tog vremena ih grupišemo i obrađujemo zajedno, vraćajući fizički svijet jednom za svaki klaster.

U ovoj fazi imamo generalno funkcionalan sistem. Njegov kod u donekle 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. Shvatite koliko ograničiti maksimalnu udaljenost kretanja u vremenu.

Bilo nam je važno da igru ​​učinimo što pristupačnijom u uslovima loših mobilnih mreža, pa smo priču ograničili sa marginom od 30 tikova (sa tick rateom od 20 Hz). Ovo omogućava igračima da pogađaju protivnike čak i pri vrlo visokim pingovima.

2. Odredite koji se objekti mogu pomjerati u vremenu, a koji ne.

Mi, naravno, pokrećemo naše protivnike. Ali energetski štitovi koji se mogu instalirati, na primjer, nisu. Odlučili smo da je bolje dati prednost odbrambenoj sposobnosti, kao što se često radi u online šuterima. Ako je igrač već postavio štit u sadašnjost, meci iz prošlosti sa kompenzacijom kašnjenja ne bi trebali proletjeti kroz njega.

3. Odlučite da li je potrebno kompenzirati sposobnosti dinosaurusa: ugriz, udarac repom, itd. Odlučili smo šta je potrebno i obradili ih po istim pravilima kao i metke.

4. Odredite šta da radite sa sudaračima igrača za koje se vrši kompenzacija zaostajanja. Na dobar način, njihova pozicija ne bi trebalo da se pomera u prošlost: igrač treba da vidi sebe u isto vreme u kojem je sada na serveru. Međutim, vraćamo i sudarače igrača koji puca, a za to postoji nekoliko razloga.

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

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

Treće, izračunavamo pozicije dinosaurovog oružja ili tačku primjene sposobnosti koristeći podatke iz ECS-a čak i prije početka kompenzacije zaostajanja.

Kao rezultat toga, stvarna pozicija sudarača igrača sa kompenzacijom kašnjenja nam je nevažna, pa smo krenuli produktivnijim i ujedno jednostavnijim putem.

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

Naravno, sve je to također moralo biti plaćeno povećanom složenošću serverskog koda u cjelini – kako za programere tako i za dizajnere igara. Ako je ranije simulacija bila jednostavan sekvencijalni poziv sistema, onda su se s kompenzacijom zaostajanja u njoj pojavile ugniježđene petlje i grane. Takođe 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 nezavisne fizičke scene. Implementirali smo ih na server skoro odmah nakon ažuriranja, jer smo željeli brzo da se riješimo fizičkog svijeta zajedničkog svim sobama.

Dali smo svakoj sobi za igru ​​svoju fizičku scenu i tako eliminisali potrebu da se scena "očisti" iz podataka susjedne sobe prije izračunavanja simulacije. Prvo, to je dalo značajno povećanje produktivnosti. Drugo, omogućio je da se riješi čitave klase grešaka koje su se pojavile ako je programer napravio grešku u kodu za čišćenje scene prilikom dodavanja novih elemenata igre. Takve greške je bilo teško otkloniti, a često su dovele do toga da se stanje fizičkih objekata u sceni jedne sobe „pretoči“ u drugu prostoriju.

Osim toga, uradili smo neka istraživanja o tome da li se fizičke scene mogu koristiti za pohranjivanje historije fizičkog svijeta. Odnosno, svakoj prostoriji, uslovno, dodijelite ne jednu scenu, već 30 scena i napravite od njih ciklički bafer u koji ćete pohraniti priču. Općenito se pokazalo da opcija funkcionira, 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 sa toliko scena. Stoga smo se pridržavali pravila: “Ako nije pukao, nemojte ga popravljati".

izvor: www.habr.com

Dodajte komentar