Kuidas täiustasime mobiilse tulistaja ballistiliste arvutuste mehaanikat võrgu latentsuse kompenseerimise algoritmiga

Kuidas täiustasime mobiilse tulistaja ballistiliste arvutuste mehaanikat võrgu latentsuse kompenseerimise algoritmiga

Tere, mina olen Nikita Brizhak, Pixonicu serveriarendaja. Täna tahaksin rääkida mobiilse mitmikmängu viivituse kompenseerimisest.

Serveri viivituse hüvitamise kohta on kirjutatud palju artikleid, sealhulgas vene keeles. See pole üllatav, kuna seda tehnoloogiat on mitme mängijaga FPS-i loomisel aktiivselt kasutatud alates 90ndate lõpust. Näiteks võite meenutada QuakeWorldi modi, mis oli üks esimesi, kes seda kasutas.

Kasutame seda ka oma mobiilses mitme mängijaga tulistamismängus Dino Squad.

Selles artiklis ei ole minu eesmärk korrata juba tuhat korda kirjutatut, vaid rääkida, kuidas rakendasime oma mängus viivituse kompenseerimist, võttes arvesse meie tehnoloogiapakki ja põhilisi mängufunktsioone.

Paar sõna meie ajukoorest ja tehnoloogiast.

Dino Squad on võrgu mobiilne PvP laskur. Mängijad juhivad erinevate relvadega varustatud dinosauruseid ja võitlevad üksteisega 6v6 meeskondades.

Nii klient kui ka server põhinevad Unityl. Arhitektuur on laskurite jaoks üsna klassikaline: server on autoritaarne ja kliendi ennustamine töötab klientide peal. Mängu simulatsioon on kirjutatud ettevõttesisese ECS-i abil ja seda kasutatakse nii serveris kui ka kliendis.

Kui kuulete viivituse hüvitamisest esimest korda, on siin lühike ülevaade selle probleemi kohta.

Mitme mängijaga FPS-mängudes simuleeritakse matši tavaliselt kaugserveris. Mängijad saadavad oma sisendi (teabe vajutatud klahvide kohta) serverisse ja vastuseks saadab server neile värskendatud mängu oleku, mis võtab arvesse saadud andmeid. Selle interaktsiooniskeemi puhul on viivitus edasi-klahvi vajutamise ja mängija tegelase ekraanil liikumise hetke vahel alati suurem kui ping.

Kui kohalikes võrkudes võib see viivitus (rahvapäraselt kutsutud sisendi viivitus) olla märkamatu, siis interneti kaudu mängides tekitab see tegelase juhtimisel “jääl libisemise” tunde. See probleem on kahekordselt aktuaalne mobiilsidevõrkude puhul, kus mängija pingi pikkust 200 ms peetakse endiselt suurepäraseks ühenduseks. Tihti võib ping olla 350, 500 või 1000 ms. Siis muutub sisendviivitusega kiire laskuri mängimine peaaegu võimatuks.

Selle probleemi lahendus on kliendipoolne simulatsiooniprognoos. Siin rakendab klient ise sisendi mängija tegelasele, ootamata serverilt vastust. Ja kui vastus on saadud, võrdleb see lihtsalt tulemusi ja värskendab vastaste positsioone. Viivitus klahvivajutuse ja tulemuse ekraanil kuvamise vahel on sel juhul minimaalne.

Siin on oluline mõista nüanssi: klient joonistab end alati vastavalt oma viimasele sisendile ja vaenlased - võrgu viivitusega, vastavalt serveri andmetele eelnevale olekule. See tähendab, et vaenlase pihta tulistades näeb mängija teda minevikus enda suhtes. Lisateavet klientide prognoosimise kohta kirjutasime varem.

Seega lahendab kliendi ennustamine ühe probleemi, kuid loob teise: kui mängija tulistab punkti, kus vaenlane oli minevikus, siis samas punktis tulistades serveris, ei pruugi vaenlane enam selles kohas olla. Serveri viivituse kompenseerimine püüab seda probleemi lahendada. Relva tulistamisel taastab server mänguseisundi, mida mängija tulistamise ajal kohapeal nägi, ja kontrollib, kas ta oleks tõesti võinud vaenlast tabada. Kui vastus on "jah", siis löök loetakse, isegi kui vaenlane pole sellel hetkel enam serveris.

Nende teadmistega relvastatud, hakkasime Dino Squadis rakendama serveri viivituse kompenseerimist. Kõigepealt pidime aru saama, kuidas taastada serveris seda, mida klient nägi? Ja mida täpselt tuleb taastada? Meie mängus arvutatakse relvade ja võimete tabamused kiirsaadete ja ülekatete kaudu – see tähendab vastasmõjude kaudu vaenlase füüsiliste põrkeseadmetega. Sellest lähtuvalt pidime serveris taasesitama nende põrkajate asukoha, mida mängija kohapeal „nägi“. Sel ajal kasutasime Unity versiooni 2018.x. Sealne füüsika API on staatiline, füüsiline maailm eksisteerib ühes eksemplaris. Selle olekut ei saa kuidagi salvestada ja seejärel kastist taastada. Mida siis teha?

Lahendus oli pinnal, kõiki selle elemente olime juba teiste probleemide lahendamiseks kasutanud:

  1. Iga kliendi puhul peame teadma, millal ta klahve vajutades vastaseid nägi. Oleme selle teabe juba sisendpaketti kirjutanud ja kasutanud seda kliendi ennustuse kohandamiseks.
  2. Peame suutma salvestada mängu olekute ajalugu. Just selles hoiame oma vastaste (ja seega ka nende põrkajate) positsioone. Meil oli serveris juba olekuajalugu, kasutasime seda ehitamiseks deltad. Õiget aega teades võiksime ajaloost hõlpsasti õige oleku üles leida.
  3. Nüüd, kui meil on ajaloost pärit mänguolek käes, peame suutma mängijaandmeid füüsilise maailma olekuga sünkroonida. Olemasolevad põrkajad - liigutage, puuduvad - looge, mittevajalikud - hävitage. See loogika oli samuti juba kirjutatud ja koosnes mitmest ECS süsteemist. Kasutasime seda mitme mängutoa korraldamiseks ühes Unity protsessis. Ja kuna füüsiline maailm on üks protsessi kohta, tuli seda ruumide vahel uuesti kasutada. Enne simulatsiooni iga linnukese tegemist "lähtestasime" füüsilise maailma oleku ja initsialiseerisime selle uuesti praeguse ruumi andmetega, püüdes Unity mänguobjekte nii palju kui võimalik nutika ühendamissüsteemi kaudu uuesti kasutada. Jäi vaid välja kutsuda sama loogika varasemast mänguseisundist.

Kõiki neid elemente kokku pannes saime “ajamasina”, mis suudab füüsilise maailma oleku õigesse hetke tagasi keerata. Kood osutus lihtsaks:

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

Jäi vaid nuputada, kuidas seda masinat kasutada, et kaadreid ja võimeid lihtsalt kompenseerida.

Lihtsamal juhul, kui mehaanika põhineb ühel tabamusskaneerimisel, näib kõik olevat selge: enne kui mängija tulistab, peab ta füüsilise maailma soovitud olekusse tagasi kerima, tegema kiirsaate, loendama tabamust või möödalaskmist ja viia maailm algsesse olekusse.

Kuid selliseid mehaanikuid on Dino Squadis väga vähe! Enamik mängus olevaid relvi loob mürske – pikaealisi kuule, mis lendavad mitme simulatsioonipuugi (mõnel juhul kümnete puukide) järele. Mida nendega teha, mis kell nad lendama peaksid?

В iidne artikkel Half-Life'i võrgupinu kohta esitasid Valve tüübid sama küsimuse ja nende vastus oli järgmine: mürsu viivituse kompenseerimine on problemaatiline ja parem on seda vältida.

Meil polnud seda võimalust: mürsupõhised relvad olid mängu disaini põhifunktsioon. Nii et me pidime midagi välja mõtlema. Pärast mõningast ajurünnakut sõnastasime kaks võimalust, mis näisid toimivat:

1. Seome mürsu selle loonud mängija ajaga. Iga serverisimulatsiooni linnuke, iga mängija iga täpi jaoks keerame füüsilise maailma tagasi kliendi olekusse ja teostame vajalikud arvutused. Selline lähenemine võimaldas serveril hajutatud koormust ja mürskude prognoositavat lennuaega. Meie jaoks oli ettearvatavus eriti oluline, kuna meil on kliendile ennustatud kõik mürsud, sealhulgas vaenlase mürsud.

Kuidas täiustasime mobiilse tulistaja ballistiliste arvutuste mehaanikat võrgu latentsuse kompenseerimise algoritmiga
Pildil linnukesega 30 olev mängija laseb ootuses raketi: ta näeb, millises suunas vaenlane jookseb, ja teab raketi ligikaudset kiirust. Kohalikult näeb ta, et tabas sihtmärki 33. linnukese juures. Tänu viivituse kompenseerimisele ilmub see ka serverisse

2. Teeme kõike samamoodi nagu esimeses variandis, kuid pärast ühe täppisimulatsiooni linnukese loendamist me ei peatu, vaid jätkame selle lennu simuleerimist sama serveri linnukese sees, viies selle aja iga kord serverile lähemale. ükshaaval linnuke ja põrkeseadmete positsioonide värskendamine. Teeme seda seni, kuni juhtub üks kahest asjast:

  • Kuul on aegunud. See tähendab, et arvutused on läbi, saame lugeda möödalasku või tabamust. Ja see on sama linnukese juures, milles lask tehti! Meie jaoks oli see nii pluss kui miinus. Pluss - kuna tulistaja jaoks vähendas see märkimisväärselt viivitust tabamuse ja vaenlase tervise halvenemise vahel. Negatiivne külg on see, et sama efekti täheldati ka siis, kui vastased tulistasid mängija pihta: näib, et vaenlane tulistas ainult aeglase raketi ja kahju oli juba loetud.
  • Kuul on jõudnud serveriaega. Sel juhul jätkub selle simulatsioon järgmise serveri linnukesega ilma viivituse kompenseerimiseta. Aeglaste mürskude puhul võib see teoreetiliselt vähendada füüsika tagasipööramiste arvu võrreldes esimese variandiga. Samal ajal suurenes simulatsiooni ebaühtlane koormus: server oli kas jõude või arvutas ühes serveri linnukeses mitme täpi kohta tosin simulatsiooni linnukest.

Kuidas täiustasime mobiilse tulistaja ballistiliste arvutuste mehaanikat võrgu latentsuse kompenseerimise algoritmiga
Sama stsenaarium, mis eelmisel pildil, kuid arvutatud teise skeemi järgi. Rakett "nähtus" serveriajast samal linnukesega, mil lask toimus, ja tabamust saab lugeda juba järgmise linnukesega. 31. linnukese juures sel juhul viivituskompensatsiooni enam ei rakendata

Meie rakendamisel erinesid need kaks lähenemisviisi vaid paari koodirea poolest, nii et lõime mõlemad ja pikka aega eksisteerisid need paralleelselt. Olenevalt relva mehaanikast ja kuuli kiirusest valisime iga dinosauruse jaoks ühe või teise variandi. Pöördepunktiks oli siin ilmumine mehaanikamängus stiilis "kui sa tabad vaenlast nii palju kordi sellisel ja sellisel ajal, saate sellise ja sellise boonuse." Iga mehaanik, kus mängija vaenlase tabamise aeg mängis olulist rolli, keeldus teise lähenemisega töötamast. Seega valisime esimese variandi ja see kehtib nüüd kõigi relvade ja kõigi mängu aktiivsete võimete kohta.

Eraldi tasub tõstatada jõudluse küsimus. Kui arvasite, et see kõik aeglustab asja, vastan: on küll. Unity liigutab põrkureid ja lülitab neid sisse ja välja üsna aeglaselt. Dino Squadis võib "halvimal" juhul olla lahingus korraga mitusada mürsku. Kokkupõrgete liigutamine iga mürsu eraldi loendamiseks on taskukohane luksus. Seetõttu oli meil absoluutselt vajalik viia füüsika “tagasipööramiste” arv miinimumini. Selleks lõime ECS-is eraldi komponendi, kuhu salvestame mängija aja. Lisasime selle kõikidele olemitele, mis nõuavad viivituse kompenseerimist (mürsud, võimed jne). Enne selliste olemite töötlemise alustamist koondame need selleks ajaks klastrisse ja töötleme neid koos, keerates iga klastri jaoks ühe korra füüsilise maailma tagasi.

Selles etapis on meil üldiselt toimiv süsteem. Selle kood mõnevõrra lihtsustatud kujul:

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

Jäi vaid detailid konfigureerida:

1. Saage aru, kui palju piirata maksimaalset liikumiskaugust ajas.

Meie jaoks oli oluline muuta mäng kehvade mobiilsidevõrkude tingimustes võimalikult ligipääsetavaks, seetõttu piirasime lugu 30 linnukese marginaaliga (tikstumissagedusega 20 Hz). See võimaldab mängijatel lüüa vastaseid isegi väga kõrgete pingide korral.

2. Määrake, milliseid objekte saab ajas liigutada ja milliseid mitte.

Loomulikult liigutame vastaseid. Aga näiteks paigaldatavad energiakilbid ei ole. Otsustasime, et parem on seada esikohale kaitsevõime, nagu seda tehakse võrgulaskurites. Kui mängija on kilbi olevikku juba asetanud, ei tohiks viivitusega kompenseeritud kuulid minevikust sellest läbi lennata.

3. Otsustage, kas on vaja kompenseerida dinosauruste võimeid: hammustada, saba lüüa jne. Otsustasime, mida vaja, ja töötleme neid samade reeglite järgi kui kuuli.

4. Tehke kindlaks, mida teha mängija põrkuritega, kelle jaoks viivituse kompenseerimist teostatakse. Heas mõttes ei tohiks nende positsioon nihkuda minevikku: mängija peaks nägema end samal ajal, kui ta praegu serveris on. Küll aga veereme tagasi ka tulistamismängija põrkeid ja sellel on mitu põhjust.

Esiteks parandab see rühmitamist: saame kasutada sama füüsilist olekut kõigi lähedaste pingitega mängijate jaoks.

Teiseks välistame kõikides kiirsaadetes ja kattumistes alati mängija põrkajad, kellele kuuluvad võimed või mürsud. Dino Squadis juhivad mängijad dinosauruseid, millel on laskuri standardite järgi üsna ebastandardne geomeetria. Isegi kui mängija tulistab ebatavalise nurga all ja kuuli trajektoor läbib mängija dinosauruste põrkurit, eirab kuul seda.

Kolmandaks arvutame ECS-i andmete põhjal välja dinosauruse relva asukohad või võime rakenduspunktid juba enne viivituse kompenseerimise algust.

Sellest tulenevalt on mahajäämuskompensatsiooniga mängija põrkajate reaalne asetus meie jaoks ebaoluline, seega läksime produktiivsema ja samas lihtsama tee peale.

Võrgu latentsust ei saa lihtsalt eemaldada, seda saab ainult maskeerida. Nagu igal teisel maskeerimismeetodil, on serveri viivituse kompenseerimisel omad kompromissid. See parandab tulistava mängija mängukogemust selle mängija arvelt, kelle pihta tulistatakse. Dino Squadi jaoks oli siinne valik aga ilmne.

Loomulikult pidi selle kõige eest maksma ka serverikoodi kui terviku suurenenud keerukus - nii programmeerijate kui ka mängudisainerite jaoks. Kui varem oli simulatsioon lihtne süsteemide järjestikune väljakutse, siis viivituse kompenseerimisega ilmusid sellesse pesastatud silmused ja harud. Samuti nägime palju vaeva, et sellega töötamine oleks mugav.

2019. aasta versioonis (ja võib-olla veidi varem) lisas Unity sõltumatute füüsiliste stseenide täieliku toe. Rakendasime need serverisse peaaegu kohe pärast uuendust, sest tahtsime kiiresti vabaneda kõikidele tubadele ühisest füüsilisest maailmast.

Andsime igale mängutoale oma füüsilise stseeni ja seega kaotasime vajaduse enne simulatsiooni arvutamist stseeni naabertoa andmetest “puhastada”. Esiteks suurendas see oluliselt tootlikkust. Teiseks võimaldas see vabaneda tervest klassist vigadest, mis tekkisid siis, kui programmeerija tegi uute mänguelementide lisamisel stseenipuhastuskoodis vea. Selliseid vigu oli raske siluda ja nende tulemuseks oli sageli ühe ruumi stseenis olevate füüsiliste objektide olek "voolamine" teise ruumi.

Lisaks uurisime, kas füüsilisi stseene saab kasutada füüsilise maailma ajaloo talletamiseks. See tähendab, et tinglikult eraldage igale ruumile mitte üks stseen, vaid 30 stseeni ja tehke neist tsükliline puhver, kuhu lugu salvestada. Üldiselt osutus variant töötavaks, kuid me seda ei rakendanud: hullu tootlikkuse kasvu see ei näidanud, vaid nõudis üsna riskantseid muudatusi. Nii paljude stseenidega pikka aega töötades oli raske ennustada, kuidas server käitub. Seetõttu järgisime reeglit: "Kui see pole murtud, ärge seda parandage'.

Allikas: www.habr.com

Lisa kommentaar