Cum am îmbunătățit mecanica calculelor balistice pentru un shooter mobil cu un algoritm de compensare a latenței rețelei

Cum am îmbunătățit mecanica calculelor balistice pentru un shooter mobil cu un algoritm de compensare a latenței rețelei

Bună, sunt Nikita Brizhak, un dezvoltator de servere de la Pixonic. Astăzi aș dori să vorbesc despre compensarea decalajului în multiplayer mobil.

S-au scris multe articole despre compensarea întârzierii serverului, inclusiv în limba rusă. Acest lucru nu este surprinzător, deoarece această tehnologie a fost utilizată în mod activ în crearea FPS-urilor multiplayer încă de la sfârșitul anilor 90. De exemplu, vă puteți aminti de modul QuakeWorld, care a fost unul dintre primii care l-au folosit.

Îl folosim și în shooter-ul nostru mobil multiplayer Dino Squad.

În acest articol, scopul meu nu este să repet ceea ce a fost deja scris de o mie de ori, ci să spun cum am implementat compensarea decalajului în jocul nostru, ținând cont de tehnologia noastră și de caracteristicile de bază ale jocului.

Câteva cuvinte despre cortexul și tehnologia noastră.

Dino Squad este un shooter PvP mobil în rețea. Jucătorii controlează dinozaurii echipați cu o varietate de arme și se luptă între ei în echipe 6v6.

Atât clientul, cât și serverul se bazează pe Unity. Arhitectura este destul de clasică pentru shooteri: serverul este autoritar, iar predicția clientului funcționează pe clienți. Simularea jocului este scrisă folosind ECS intern și este folosită atât pe server, cât și pe client.

Dacă este prima dată când auziți despre compensarea decalajului, iată o scurtă excursie în această problemă.

În jocurile FPS multiplayer, meciul este de obicei simulat pe un server la distanță. Jucătorii își trimit intrarea (informații despre tastele apăsate) către server, iar ca răspuns serverul le trimite o stare actualizată a jocului ținând cont de datele primite. Cu această schemă de interacțiune, întârzierea dintre apăsarea tastei înainte și momentul în care personajul jucătorului se mișcă pe ecran va fi întotdeauna mai mare decât ping-ul.

În timp ce pe rețelele locale, această întârziere (numită în mod obișnuit întârziere de intrare) poate fi inobservabilă, atunci când se joacă prin Internet creează o senzație de „alunecare pe gheață” atunci când controlezi un personaj. Această problemă este de două ori relevantă pentru rețelele mobile, unde cazul în care ping-ul unui jucător este de 200 ms este încă considerat o conexiune excelentă. Adesea, ping-ul poate fi de 350, 500 sau 1000 ms. Atunci devine aproape imposibil să joci un shooter rapid cu întârziere de intrare.

Soluția la această problemă este predicția de simulare pe partea clientului. Aici clientul însuși aplică intrarea personajului jucătorului, fără a aștepta un răspuns de la server. Și când este primit răspunsul, pur și simplu compară rezultatele și actualizează pozițiile adversarilor. Întârzierea dintre apăsarea unei taste și afișarea rezultatului pe ecran în acest caz este minimă.

Este important să înțelegeți nuanța aici: clientul se desenează întotdeauna în funcție de ultima sa intrare, iar inamicii - cu întârziere de rețea, în funcție de starea anterioară din datele de pe server. Adică, când trage într-un inamic, jucătorul îl vede în trecut relativ la el însuși. Mai multe despre predicția clientului am scris mai devreme.

Astfel, predicția clientului rezolvă o problemă, dar creează alta: dacă un jucător trage în punctul în care era inamicul în trecut, pe server când trage în același punct, este posibil ca inamicul să nu mai fie în acel loc. Compensarea lagului serverului încearcă să rezolve această problemă. Când o armă este trasă, serverul restabilește starea de joc pe care jucătorul a văzut-o local în momentul împușcăturii și verifică dacă într-adevăr ar fi putut lovi inamicul. Dacă răspunsul este „da”, lovitura este numărată, chiar dacă inamicul nu mai este pe server în acel moment.

Înarmați cu aceste cunoștințe, am început să implementăm compensarea întârzierii serverului în Dino Squad. În primul rând, a trebuit să înțelegem cum să restabilim pe server ceea ce a văzut clientul? Și ce anume trebuie restaurat? În jocul nostru, loviturile de la arme și abilități sunt calculate prin radiații și suprapuneri - adică prin interacțiuni cu ciocnitorii fizici ai inamicului. În consecință, trebuia să reproducem poziția acestor ciocnitori, pe care jucătorul le-a „văzut” local, pe server. La acel moment folosim Unity versiunea 2018.x. API-ul fizicii de acolo este static, lumea fizică există într-o singură copie. Nu există nicio modalitate de a-i salva starea și apoi de a o restaura din cutie. Deci ce să fac?

Soluția era la suprafață; toate elementele sale fuseseră deja folosite de noi pentru a rezolva alte probleme:

  1. Pentru fiecare client, trebuie să știm la ce oră a văzut adversarii când a apăsat tastele. Am scris deja aceste informații în pachetul de intrare și le-am folosit pentru a ajusta predicția clientului.
  2. Trebuie să putem stoca istoricul stărilor de joc. În ea vom menține pozițiile adversarilor noștri (și, prin urmare, ciocnitorii lor). Aveam deja un istoric de stat pe server, l-am folosit pentru a construi delte. Cunoscând momentul potrivit, am putea găsi cu ușurință starea potrivită în istorie.
  3. Acum că avem în mână starea jocului din istorie, trebuie să fim capabili să sincronizăm datele jucătorului cu starea lumii fizice. Ciocnitorii existenți - mutați, cei lipsă - creați, cei inutile - distrugeți. Această logică a fost deja scrisă și a constat din mai multe sisteme ECS. L-am folosit pentru a ține mai multe săli de joc într-un proces Unity. Și din moment ce lumea fizică este una pentru fiecare proces, a trebuit să fie refolosită între camere. Înainte de fiecare bifă a simulării, „resetăm” starea lumii fizice și am reinițializat-o cu date pentru camera curentă, încercând să reutilizam pe cât posibil obiectele jocului Unity printr-un sistem inteligent de pooling. Tot ce a rămas a fost să invocem aceeași logică pentru starea de joc din trecut.

Punând toate aceste elemente împreună, am obținut o „mașină a timpului” care ar putea întoarce starea lumii fizice la momentul potrivit. Codul s-a dovedit a fi simplu:

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

Tot ce a rămas a fost să ne dai seama cum să folosești această mașină pentru a compensa cu ușurință fotografiile și abilitățile.

În cel mai simplu caz, când mecanica se bazează pe un singur hit-scan, totul pare să fie clar: înainte ca jucătorul să tragă, trebuie să facă înapoi lumea fizică la starea dorită, să facă un raycast, să numere hit-ul sau ratarea și întoarce lumea la starea inițială.

Dar există foarte puține astfel de mecanici în Dino Squad! Cele mai multe dintre armele din joc creează proiectile - gloanțe cu viață lungă care zboară pentru mai multe căpușe de simulare (în unele cazuri, zeci de căpușe). Ce să faci cu ei, la ce oră ar trebui să zboare?

В articol antic despre stiva de rețea Half-Life, băieții de la Valve au pus aceeași întrebare, iar răspunsul lor a fost următorul: compensarea decalajului proiectilului este problematică și este mai bine să o evitați.

Nu aveam această opțiune: armele bazate pe proiectile erau o caracteristică cheie a designului jocului. Așa că a trebuit să venim cu ceva. După câteva brainstorming, am formulat două opțiuni care păreau să funcționeze:

1. Legăm proiectilul de ora jucătorului care l-a creat. Fiecare bifă a simulării serverului, pentru fiecare glonț al fiecărui jucător, facem înapoi lumea fizică la starea client și efectuăm calculele necesare. Această abordare a făcut posibilă o sarcină distribuită pe server și un timp de zbor previzibil al proiectilelor. Previzibilitatea a fost deosebit de importantă pentru noi, deoarece avem toate proiectilele, inclusiv proiectilele inamice, prezise asupra clientului.

Cum am îmbunătățit mecanica calculelor balistice pentru un shooter mobil cu un algoritm de compensare a latenței rețelei
În imagine, jucătorul de la bifa 30 trage o rachetă în așteptare: vede în ce direcție merge inamicul și știe viteza aproximativă a rachetei. Local vede că a lovit ținta la al 33-lea tic. Datorită compensării lag-ului, va apărea și pe server

2. Facem totul la fel ca în prima opțiune, dar, după ce am numărat un tick din simularea glonțului, nu ne oprim, ci continuăm să simulăm zborul acestuia în cadrul aceluiași tick de server, apropiindu-și de fiecare dată timpul de server. bifează unul câte unul și actualizează pozițiile coliderului. Facem asta până când se întâmplă unul dintre cele două lucruri:

  • Glonțul a expirat. Asta înseamnă că calculele s-au terminat, putem număra o ratare sau o lovitură. Și asta la același tic în care s-a tras focul! Pentru noi acest lucru a fost atât un plus, cât și un minus. Un plus - deoarece pentru jucătorul care împușcă acest lucru a redus semnificativ întârzierea dintre lovitură și scăderea sănătății inamicului. Dezavantajul este că același efect a fost observat și atunci când adversarii au tras în jucător: inamicul, s-ar părea, a tras doar o rachetă lentă, iar pagubele erau deja numărate.
  • Glonțul a ajuns la ora serverului. În acest caz, simularea sa va continua în următorul bif al serverului fără nicio compensare a decalajului. Pentru proiectilele lente, acest lucru ar putea reduce teoretic numărul de retrocedări ale fizicii în comparație cu prima opțiune. În același timp, sarcina neuniformă a simulării a crescut: serverul fie era inactiv, fie într-un tick de server calcula o duzină de tick-uri de simulare pentru mai multe gloanțe.

Cum am îmbunătățit mecanica calculelor balistice pentru un shooter mobil cu un algoritm de compensare a latenței rețelei
Același scenariu ca în poza anterioară, dar calculat conform celei de-a doua scheme. Racheta „a ajuns din urmă” cu ora serverului la același tic în care a avut loc împușcătura, iar lovitura poate fi numărată încă de la următorul tic. La a 31-a bifă, în acest caz, nu se mai aplică compensarea lagului

În implementarea noastră, aceste două abordări au fost diferite în doar câteva linii de cod, așa că le-am creat pe amândouă și pentru o lungă perioadă de timp au existat în paralel. În funcție de mecanica armei și de viteza glonțului, am ales una sau alta opțiune pentru fiecare dinozaur. Punctul de cotitură aici a fost apariția în jocul mecanicilor de genul „dacă loviți inamicul de atâtea ori într-un moment, obțineți un bonus”. Orice mecanic în care momentul în care jucătorul a lovit inamicul a jucat un rol important a refuzat să lucreze cu a doua abordare. Așa că am ajuns la prima opțiune, iar acum se aplică tuturor armelor și tuturor abilităților active din joc.

Separat, merită să ridicați problema performanței. Dacă ai crezut că toate acestea vor încetini lucrurile, eu răspund: este. Unity este destul de lentă în mișcarea ciocnitorilor și în pornirea și oprirea acestora. În Dino Squad, în „cel mai rău” caz, pot exista câteva sute de proiectile simultan în luptă. Mutarea colisionarelor pentru a număra fiecare proiectil în mod individual este un lux inaccesibil. Prin urmare, a fost absolut necesar pentru noi să minimizăm numărul de „retroduceri” din fizică. Pentru a face acest lucru, am creat o componentă separată în ECS în care înregistrăm timpul jucătorului. L-am adăugat tuturor entităților care necesită compensare lag (proiectile, abilități etc.). Înainte de a începe procesarea unor astfel de entități, le grupăm până la acest moment și le procesăm împreună, dând înapoi lumea fizică o dată pentru fiecare cluster.

În această etapă avem un sistem general funcțional. Codul său într-o formă oarecum simplificată:

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

Tot ce a rămas a fost configurarea detaliilor:

1. Înțelegeți cât să limitați distanța maximă de mișcare în timp.

Pentru noi a fost important să facem jocul cât mai accesibil în condițiile unor rețele mobile slabe, așa că am limitat povestea cu o marjă de 30 de tick (cu o rată de tick de 20 Hz). Acest lucru le permite jucătorilor să lovească adversarii chiar și la ping-uri foarte mari.

2. Stabiliți ce obiecte pot fi mutate în timp și care nu.

Desigur, ne mutăm adversarii. Dar scuturile de energie instalabile, de exemplu, nu sunt. Am decis că este mai bine să acordăm prioritate capacității defensive, așa cum se face adesea în shooter-urile online. Dacă jucătorul a plasat deja un scut în prezent, gloanțe compensate cu întârziere din trecut nu ar trebui să zboare prin el.

3. Decideți dacă este necesar să compensați abilitățile dinozaurilor: mușcătură, lovitură de coadă etc. Am decis ce este necesar și le procesăm după aceleași reguli ca și gloanțe.

4. Stabiliți ce să faceți cu ciocnitorii jucătorului pentru care se efectuează compensarea decalajului. Într-un sens bun, poziția lor nu ar trebui să se schimbe în trecut: jucătorul ar trebui să se vadă în același timp în care se află acum pe server. Cu toate acestea, de asemenea, facem înapoi și ciocnitoarele jucătorului care trage și există mai multe motive pentru acest lucru.

În primul rând, îmbunătățește gruparea: putem folosi aceeași stare fizică pentru toți jucătorii cu ping-uri apropiate.

În al doilea rând, în toate radiațiile și suprapunerile excludem întotdeauna ciocnitorii jucătorului care deține abilitățile sau proiectilele. În Dino Squad, jucătorii controlează dinozaurii, care au o geometrie destul de nestandardă pentru standardele shooter-ului. Chiar dacă jucătorul trage într-un unghi neobișnuit și traiectoria glonțului trece prin ciocnitorul de dinozauri al jucătorului, glonțul îl va ignora.

În al treilea rând, calculăm pozițiile armei dinozaurului sau punctul de aplicare a capacității folosind date din ECS chiar înainte de începerea compensării întârzierii.

Drept urmare, poziția reală a ciocnitorilor jucătorului compensat cu întârziere este neimportantă pentru noi, așa că am luat o cale mai productivă și în același timp mai simplă.

Latența rețelei nu poate fi pur și simplu eliminată, poate fi doar mascata. Ca orice altă metodă de deghizare, compensarea întârzierii serverului are compromisurile sale. Îmbunătățește experiența de joc a jucătorului care trage în detrimentul jucătorului în care este împușcat. Pentru Dino Squad, însă, alegerea aici a fost evidentă.

Desigur, toate acestea trebuiau plătite și prin complexitatea crescută a codului serverului în ansamblu - atât pentru programatori, cât și pentru designerii de jocuri. Dacă mai devreme simularea a fost un simplu apel secvenţial al sistemelor, atunci cu compensare de decalaj, au apărut bucle imbricate şi ramuri în ea. De asemenea, am depus mult efort pentru a face lucrul convenabil cu el.

În versiunea din 2019 (și poate puțin mai devreme), Unity a adăugat suport complet pentru scenele fizice independente. Le-am implementat pe server aproape imediat după actualizare, pentru că am vrut să scăpăm rapid de lumea fizică comună tuturor camerelor.

Am dat fiecărei săli de joc propria scenă fizică și am eliminat astfel nevoia de a „șterge” scena din datele camerei vecine înainte de a calcula simularea. În primul rând, a dat o creștere semnificativă a productivității. În al doilea rând, a făcut posibilă scăparea de o întreagă clasă de erori care au apărut dacă programatorul a făcut o eroare în codul de curățare a scenei când a adăugat elemente noi de joc. Astfel de erori au fost dificil de depanat și au dus adesea la starea obiectelor fizice din scena unei camere „curgând” în altă cameră.

În plus, am făcut câteva cercetări pentru a stabili dacă scenele fizice ar putea fi folosite pentru a stoca istoria lumii fizice. Adică, în mod condiționat, nu alocați o scenă fiecărei camere, ci 30 de scene și faceți din ele un tampon ciclic, în care să stocați povestea. În general, opțiunea s-a dovedit a funcționa, dar nu am implementat-o: nu a arătat nicio creștere nebună a productivității, ci a necesitat modificări destul de riscante. Era greu de prezis cum se va comporta serverul atunci când lucrează mult timp cu atâtea scene. Prin urmare, am respectat regula: „Dacă nu este rupt, nu reparați-l".

Sursa: www.habr.com

Adauga un comentariu