Kako smo izboljšali mehaniko balističnih izračunov za mobilnega strelca z algoritmom za kompenzacijo omrežne zakasnitve

Kako smo izboljšali mehaniko balističnih izračunov za mobilnega strelca z algoritmom za kompenzacijo omrežne zakasnitve

Živijo, sem Nikita Brizhak, razvijalec strežnikov pri Pixonic. Danes bi rad govoril o kompenzaciji zaostanka v mobilni igri za več igralcev.

O kompenzaciji zaostanka strežnika je bilo napisanih veliko člankov, tudi v ruščini. To ni presenetljivo, saj se ta tehnologija aktivno uporablja pri ustvarjanju FPS za več igralcev od poznih 90-ih. Na primer, lahko se spomnite moda QuakeWorld, ki je bil eden prvih, ki ga je uporabil.

Uporabljamo ga tudi v naši mobilni večigralski streljačini Dino Squad.

V tem članku moj cilj ni ponoviti že tisočkrat napisanega, temveč povedati, kako smo v naši igri implementirali kompenzacijo zaostanka, pri čemer smo upoštevali naš tehnološki sklad in osnovne značilnosti igranja.

Nekaj ​​besed o našem korteksu in tehnologiji.

Dino Squad je omrežna mobilna PvP streljačina. Igralci nadzorujejo dinozavre, opremljene z različnimi orožji, in se borijo med seboj v ekipah 6 proti 6.

Tako odjemalec kot strežnik temeljita na Unity. Arhitektura je precej klasična za strelce: strežnik je avtoritaren, napovedovanje odjemalcev pa deluje na odjemalcih. Simulacija igre je napisana z uporabo internega ECS in se uporablja na strežniku in odjemalcu.

Če prvič slišite za kompenzacijo zamika, je tukaj kratek izlet v to vprašanje.

V igrah FPS za več igralcev je tekma običajno simulirana na oddaljenem strežniku. Igralci pošljejo svoj vnos (informacije o pritisnjenih tipkah) na strežnik, v odgovor pa jim strežnik pošlje posodobljeno stanje igre ob upoštevanju prejetih podatkov. S to interakcijsko shemo bo zakasnitev med pritiskom tipke naprej in trenutkom, ko se igralčev lik premakne na zaslonu, vedno večja od pinga.

Medtem ko je v lokalnih omrežjih ta zakasnitev (popularno imenovana input lag) morda neopazna, pri igranju prek interneta ustvarja občutek "drsenja po ledu" pri upravljanju lika. Ta problem je dvojno aktualen za mobilna omrežja, kjer se primer, ko je igralčev ping 200 ms, še vedno šteje za odlično povezavo. Pogosto je lahko ping 350, 500 ali 1000 ms. Potem postane skoraj nemogoče igrati hiter strelec z vhodnim zamikom.

Rešitev te težave je napoved simulacije na strani odjemalca. Tukaj odjemalec sam uporabi vnos za igralčev lik, ne da bi čakal na odgovor strežnika. In ko prejme odgovor, preprosto primerja rezultate in posodobi položaje nasprotnikov. Zakasnitev med pritiskom na tipko in prikazom rezultata na zaslonu je v tem primeru minimalna.

Tukaj je pomembno razumeti nianso: odjemalec se vedno nariše glede na zadnji vnos, sovražniki pa - z omrežno zamudo, glede na prejšnje stanje iz podatkov s strežnika. Se pravi, ko strelja na sovražnika, ga igralec vidi v preteklosti glede na sebe. Več o napovedovanju strank smo prej pisali.

Tako predvidevanje odjemalca reši eno težavo, ustvari pa drugo: če igralec strelja na točko, kjer je bil v preteklosti sovražnik, na strežniku, ko strelja na isto točko, sovražnika morda ni več na tem mestu. Kompenzacija zamika strežnika poskuša rešiti to težavo. Ko je orožje sproženo, strežnik obnovi stanje igre, ki ga je igralec videl lokalno v času strela, in preveri, ali bi res lahko zadel sovražnika. Če je odgovor "da", se zadetek šteje, tudi če sovražnika na tej točki ni več na strežniku.

Oboroženi s tem znanjem smo začeli izvajati kompenzacijo zamika strežnika v Dino Squad. Najprej smo morali razumeti, kako obnoviti na strežniku, kar je videla stranka? In kaj točno je treba obnoviti? V naši igri se zadetki iz orožja in sposobnosti izračunavajo z oddajanjem žarkov in prekrivanjem – to je z interakcijami s sovražnikovimi fizičnimi trkalniki. V skladu s tem smo morali položaj teh trkalnikov, ki jih je igralec »videl« lokalno, reproducirati na strežniku. Takrat smo uporabljali različico Unity 2018.x. API za fiziko je statičen, fizični svet obstaja v eni sami kopiji. Ni načina, da bi shranili njegovo stanje in ga nato obnovili iz škatle. Torej, kaj narediti?

Rešitev je bila na površini, vse njene elemente smo že uporabili pri reševanju drugih problemov:

  1. Za vsako stranko moramo vedeti, kdaj je videl nasprotnike, ko je pritisnil tipke. Te informacije smo že zapisali v vhodni paket in jih uporabili za prilagajanje napovedi odjemalca.
  2. Moramo biti sposobni shraniti zgodovino stanj igre. V njem bomo držali položaje naših nasprotnikov (in s tem njihovih trkalcev). Na strežniku smo že imeli zgodovino stanja, uporabili smo jo za gradnjo delte. Če bi poznali pravi čas, bi zlahka našli pravo stanje v zgodovini.
  3. Zdaj, ko imamo v roki stanje igre iz zgodovine, moramo imeti možnost sinhronizirati podatke o igralcu s stanjem fizičnega sveta. Obstoječi trkalniki - premaknite, manjkajoče - ustvarite, nepotrebne - uničite. Tudi ta logika je bila že napisana in je sestavljena iz več sistemov ECS. Uporabili smo ga za shranjevanje več igralnih sob v enem procesu Unity. In ker je fizični svet en na proces, ga je bilo treba znova uporabiti med sobami. Pred vsako kljukico simulacije smo "ponastavili" stanje fizičnega sveta in ga znova inicializirali s podatki za trenutno sobo, pri čemer smo poskušali znova uporabiti objekte igre Unity, kolikor je to mogoče, prek pametnega sistema združevanja. Vse, kar je ostalo, je bilo priklicati isto logiko za stanje igre iz preteklosti.

Z združitvijo vseh teh elementov smo dobili »časovni stroj«, ki bi lahko zavrtel stanje fizičnega sveta na pravi trenutek. Koda se je izkazala za preprosto:

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

Preostalo je le ugotoviti, kako s tem strojem enostavno kompenzirati udarce in sposobnosti.

V najpreprostejšem primeru, ko mehanika temelji na enem samem skeniranju zadetkov, se zdi, da je vse jasno: preden igralec ustreli, mora povrniti fizični svet v želeno stanje, narediti raycast, prešteti zadetek ali zgrešeni udarec in vrniti svet v začetno stanje.

Toda takih mehanikov je v Dino Squadu zelo malo! Večina orožij v igri ustvarja izstrelke - dolgožive krogle, ki letijo več simulacijskih klopov (v nekaterih primerih na desetine klopov). Kaj storiti z njimi, ob kateri uri naj letijo?

В starodavni članek glede omrežnega sklada Half-Life so fantje iz Valvea zastavili isto vprašanje in njihov odgovor je bil naslednji: kompenzacija zakasnitve projektila je problematična in bolje se ji je izogniti.

Te možnosti nismo imeli: orožje na osnovi izstrelkov je bilo ključna značilnost zasnove igre. Zato smo se morali nekaj domisliti. Po krajšem premišljevanju smo oblikovali dve možnosti, za kateri se je zdelo, da delujeta:

1. Projektil vežemo na čas igralca, ki ga je ustvaril. Vsako kljukico strežniške simulacije, za vsako kroglo vsakega igralca povrnemo fizični svet v stanje odjemalca in izvedemo potrebne izračune. Ta pristop je omogočil porazdeljeno obremenitev strežnika in predvidljiv čas letenja izstrelkov. Predvidljivost je bila za nas še posebej pomembna, saj imamo na naročniku predvidene vse izstrelke, tudi sovražnikove.

Kako smo izboljšali mehaniko balističnih izračunov za mobilnega strelca z algoritmom za kompenzacijo omrežne zakasnitve
Na sliki igralec pri 30. kljukici v pričakovanju izstreli izstrelek: vidi, v katero smer teče sovražnik, in pozna približno hitrost izstrelka. Lokalno vidi, da je zadel tarčo pri 33. kljukici. Zahvaljujoč kompenzaciji zamika se bo pojavil tudi na strežniku

2. Naredimo vse enako kot v prvi možnosti, vendar, ko preštejemo en tik simulacije krogle, se ne ustavimo, ampak nadaljujemo s simulacijo njegovega leta znotraj istega strežniškega tika, vsakič približamo svoj čas strežniku eno za drugo kljukico in posodabljanje položajev trkalnika. To počnemo, dokler se ne zgodi ena od dveh stvari:

  • Krogla je potekla. To pomeni, da je računanja konec, lahko štejemo zgrešeni ali zadeti. In to ob istem tiku, v katerem je bil izstreljen strel! Za nas je bil to tako plus kot minus. Plus - ker je za igralca, ki strelja, to znatno zmanjšalo zamudo med udarcem in zmanjšanjem zdravja sovražnika. Slaba stran je, da je bil enak učinek opažen, ko so nasprotniki streljali na igralca: sovražnik je, kot kaže, izstrelil le počasno raketo in škoda je bila že prešteta.
  • Metka je dosegla čas strežnika. V tem primeru se bo njegova simulacija nadaljevala v naslednjem strežniškem tiku brez kompenzacije zamika. Za počasne izstrelke bi to teoretično lahko zmanjšalo število vračanj fizike v primerjavi s prvo možnostjo. Hkrati se je povečala neenakomerna obremenitev simulacije: strežnik je bodisi miroval bodisi je v enem tiku strežnika izračunal ducat simulacijskih tikov za več krogel.

Kako smo izboljšali mehaniko balističnih izračunov za mobilnega strelca z algoritmom za kompenzacijo omrežne zakasnitve
Enak scenarij kot na prejšnji sliki, vendar izračunan po drugi shemi. Projektil je "dohitel" čas strežnika ob istem tiku, ko je prišlo do strela, zadetek pa se lahko šteje že pri naslednjem tiku. Pri 31. kljukici se v tem primeru kompenzacija zamika ne uporablja več

V naši implementaciji sta se ta dva pristopa razlikovala v le nekaj vrsticah kode, zato smo ustvarili oba in dolgo sta obstajala vzporedno. Glede na mehaniko orožja in hitrost krogle smo za vsakega dinozavra izbrali eno ali drugo možnost. Prelomnica je bila pojav v igri mehanike, kot je "če udariš sovražnika tolikokrat v takem in takem času, dobiš tak in tak bonus." Kateri koli mehanik, pri katerem je imel pomembno vlogo čas, ko je igralec udaril sovražnika, je zavrnil delo z drugim pristopom. Tako smo na koncu izbrali prvo možnost, ki zdaj velja za vsa orožja in vse aktivne sposobnosti v igri.

Ločeno je vredno izpostaviti vprašanje uspešnosti. Če ste mislili, da bo vse to upočasnilo stvari, odgovarjam: je. Unity precej počasi premika trkalnike ter jih prižiga in izklaplja. V Dino Squad, v "najslabšem" primeru, lahko v boju hkrati obstaja več sto izstrelkov. Premikanje trkalnikov za štetje vsakega izstrelka posebej je razkošje, ki si ga ni mogoče privoščiti. Zato je bilo absolutno nujno, da zmanjšamo število fizikalnih »povratkov«. Za to smo v ECS ustvarili ločeno komponento, v kateri beležimo čas igralca. Dodali smo ga vsem entitetam, ki zahtevajo kompenzacijo zamika (projektili, sposobnosti itd.). Preden začnemo obdelovati takšne entitete, jih do tega trenutka združimo v gruče in obdelamo skupaj, tako da fizični svet enkrat vrnemo nazaj za vsako gručo.

Na tej stopnji imamo na splošno delujoč sistem. Njegova koda v nekoliko poenostavljeni obliki:

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

Vse, kar je ostalo, je bilo konfigurirati podrobnosti:

1. Razumeti, koliko omejiti največjo razdaljo gibanja v času.

Pomembno nam je bilo narediti igro čim bolj dostopno v razmerah slabih mobilnih omrežij, zato smo zgodbo omejili z rezervo 30 tikov (s hitrostjo tikov 20 Hz). To omogoča igralcem, da udarijo nasprotnike tudi pri zelo visokih pingih.

2. Ugotovite, katere predmete je mogoče premakniti v času in katere ne.

Mi pa seveda premikamo nasprotnike. Toda vgradljivi energetski ščiti na primer niso. Odločili smo se, da je bolje dati prednost obrambni sposobnosti, kot se to pogosto počne pri spletnih streljačinah. Če je igralec že postavil ščit v sedanjosti, krogle iz preteklosti s kompenziranim zamikom ne bi smele leteti skozenj.

3. Odločite se, ali je treba kompenzirati sposobnosti dinozavrov: ugriz, udarec z repom itd. Odločili smo se, kaj je potrebno, in jih obdelamo po enakih pravilih kot krogle.

4. Določite, kaj storiti s trkalniki igralca, za katerega se izvaja kompenzacija zaostanka. V dobrem smislu se njihov položaj ne bi smel premakniti v preteklost: igralec bi se moral videti v istem času, v katerem je zdaj na strežniku. Vendar tudi trkalnike strelnega igralca vrnemo nazaj in za to je več razlogov.

Prvič, izboljša združevanje v gruče: uporabimo lahko isto fizično stanje za vse igralce s tesnimi pingi.

Drugič, pri vseh raycastih in prekrivanju vedno izključimo trkalnike igralca, ki ima v lasti sposobnosti ali izstrelke. V igri Dino Squad igralci nadzorujejo dinozavre, ki imajo glede na strelske standarde precej nestandardno geometrijo. Tudi če igralec strelja pod nenavadnim kotom in gre pot krogle skozi igralčev trkalnik dinozavrov, bo krogla to prezrla.

Tretjič, izračunamo položaje dinozavrovega orožja ali točke uporabe sposobnosti z uporabo podatkov iz ECS še pred začetkom kompenzacije zamika.

Zaradi tega je realna pozicija trkalcev igralca s kompenziranim zamikom za nas nepomembna, zato smo ubrali bolj produktivno in hkrati enostavnejšo pot.

Omrežne zakasnitve ni mogoče enostavno odstraniti, lahko jo le prikrijemo. Kot vsaka druga metoda prikrivanja ima tudi kompenzacija zamika strežnika svoje kompromise. Izboljša igralno izkušnjo igralca, ki strelja, na račun igralca, na katerega se strelja. Za Dino Squad pa je bila izbira očitna.

Seveda je bilo treba vse to plačati tudi s povečano kompleksnostjo strežniške kode kot celote – tako za programerje kot za oblikovalce iger. Če je bila prej simulacija preprost zaporedni klic sistemov, potem so se s kompenzacijo zamika v njej pojavile ugnezdene zanke in veje. Prav tako smo vložili veliko truda, da je bilo priročno delati.

V različici 2019 (in morda malo prej) je Unity dodal popolno podporo za neodvisne fizične prizore. Na strežnik smo jih implementirali skoraj takoj po posodobitvi, ker smo se želeli hitro znebiti fizičnega sveta, ki je skupen vsem sobam.

Vsaki igralni sobi smo dali lastno fizično sceno in tako odpravili potrebo po »čiščenju« scene iz podatkov sosednje sobe pred izračunom simulacije. Prvič, to je znatno povečalo produktivnost. Drugič, omogočilo je, da se znebite celotnega razreda hroščev, ki so se pojavili, če je programer naredil napako v kodi za čiščenje scene pri dodajanju novih elementov igre. Takšne napake je bilo težko odpraviti in pogosto so povzročile, da je stanje fizičnih predmetov v prizorišču ene sobe "preteklo" v drugo sobo.

Poleg tega smo opravili nekaj raziskav o tem, ali bi fizične prizore lahko uporabili za shranjevanje zgodovine fizičnega sveta. To pomeni, da vsaki sobi pogojno ne dodelite enega prizora, ampak 30 prizorov in iz njih naredite ciklični medpomnilnik, v katerega shranite zgodbo. Na splošno se je izkazalo, da možnost deluje, vendar je nismo izvedli: ni pokazala nobenega norega povečanja produktivnosti, vendar je zahtevala precej tvegane spremembe. Težko je bilo napovedati, kako se bo strežnik obnašal pri dolgotrajnem delu s toliko prizori. Zato smo se držali pravila: »Če se ne zlomi, ga ne popravi".

Vir: www.habr.com

Dodaj komentar