Kuinka paransimme ballististen laskelmien mekaniikkaa mobiiliampujalle verkon latenssin kompensointialgoritmilla

Kuinka paransimme ballististen laskelmien mekaniikkaa mobiiliampujalle verkon latenssin kompensointialgoritmilla

Hei, olen Nikita Brizhak, Pixonicin palvelinkehittäjä. Tänään haluaisin puhua mobiilimoninpelin viiveen kompensoimisesta.

Palvelimen viiveen kompensoinnista on kirjoitettu monia artikkeleita, myös venäjäksi. Tämä ei ole yllättävää, koska tätä tekniikkaa on käytetty aktiivisesti moninpeli FPS:n luomisessa 90-luvun lopulta lähtien. Voit esimerkiksi muistaa QuakeWorld-modin, joka oli yksi ensimmäisistä, jotka käyttivät sitä.

Käytämme sitä myös mobiilimoninpelissämme Dino Squadissa.

Tässä artikkelissa tavoitteeni ei ole toistaa jo tuhat kertaa kirjoitettua, vaan kertoa, kuinka toteutimme viivekompensaation pelissämme ottaen huomioon teknologiapinomme ja pelin ydinominaisuudet.

Muutama sana aivokuoresta ja teknologiasta.

Dino Squad on verkon mobiili PvP-ampuja. Pelaajat ohjaavat dinosauruksia, jotka on varustettu erilaisilla aseilla, ja taistelevat toisiaan vastaan ​​6v6-joukkueissa.

Sekä asiakas että palvelin perustuvat Unityyn. Arkkitehtuuri on varsin klassinen ampujalle: palvelin on autoritaarinen ja asiakasennustus toimii asiakkaiden päällä. Pelisimulaatio on kirjoitettu käyttämällä talon sisäistä ECS:ää ja sitä käytetään sekä palvelimella että asiakaskoneella.

Jos tämä on ensimmäinen kerta, kun kuulet viiveen korvaamisesta, tässä on lyhyt katsaus asiaan.

Moninpelissä FPS-peleissä ottelua simuloidaan yleensä etäpalvelimella. Pelaajat lähettävät syötteensä (tiedot painetuista näppäimistä) palvelimelle ja vastauksena palvelin lähettää heille päivitetyn pelin tilan, jossa otetaan huomioon vastaanotetut tiedot. Tässä vuorovaikutusmallissa viive eteenpäin-näppäimen painalluksen ja pelaajan hahmon ruudulla liikkumisen välillä on aina suurempi kuin ping.

Paikallisissa verkoissa tämä viive (yleisesti kutsuttu input lag) voi olla huomaamaton, kun taas Internetin kautta pelattaessa se saa aikaan "liukumisen jäällä" hahmoa ohjattaessa. Tämä ongelma on tuplasti tärkeä mobiiliverkoissa, joissa tapausta, jossa pelaajan ping on 200 ms, pidetään edelleen erinomaisena yhteyteen. Usein ping voi olla 350, 500 tai 1000 ms. Sitten on lähes mahdotonta pelata nopeaa räiskintäpeliä syöttöviiveellä.

Ratkaisu tähän ongelmaan on asiakaspuolen simulaatioennustus. Tässä asiakas käyttää itse syötteen pelaajahahmolle odottamatta vastausta palvelimelta. Ja kun vastaus saadaan, se yksinkertaisesti vertaa tuloksia ja päivittää vastustajien asemat. Viive näppäimen painamisen ja tuloksen näytölle näyttämisen välillä on tässä tapauksessa minimaalinen.

Tässä on tärkeää ymmärtää vivahde: ​​asiakas piirtää itsensä aina viimeisen syötteensä mukaan ja viholliset - verkkoviiveellä, edellisen tilan mukaan palvelimen tiedoista. Eli ampuessaan vihollista pelaaja näkee hänet menneisyydessä suhteessa itseensä. Lisää asiakasennusteesta kirjoitimme aiemmin.

Näin ollen asiakkaan ennustus ratkaisee yhden ongelman, mutta luo toisen: jos pelaaja ampuu kohtaan, jossa vihollinen oli aiemmin, palvelimella ampuessaan samassa kohdassa, vihollinen ei ehkä enää ole siinä paikassa. Palvelimen viiveen kompensointi yrittää ratkaista tämän ongelman. Kun ase ammutaan, palvelin palauttaa pelitilan, jonka pelaaja näki paikallisesti laukauksen aikana, ja tarkistaa, olisiko hän todella voinut osua viholliseen. Jos vastaus on "kyllä", osuma lasketaan, vaikka vihollinen ei olisi enää palvelimella siinä vaiheessa.

Tämän tiedon avulla aloimme ottaa käyttöön palvelimen viiveen kompensointia Dino Squadissa. Ensinnäkin meidän piti ymmärtää kuinka palauttaa palvelimelle se, mitä asiakas näki? Ja mitä tarkalleen ottaen pitää palauttaa? Pelissämme aseiden ja kykyjen osumat lasketaan sädelähetysten ja peittojen avulla – eli vuorovaikutusten kautta vihollisen fyysisten törmäyslaitteiden kanssa. Näin ollen meidän piti toistaa näiden törmäyslaitteiden sijainti, jonka pelaaja "näki" paikallisesti palvelimella. Käytimme tuolloin Unity-versiota 2018.x. Siellä oleva fysiikan API on staattinen, fyysinen maailma on olemassa yhtenä kopiona. Sen tilaa ei voi tallentaa ja sitten palauttaa laatikosta. Eli mikä neuvoksi?

Ratkaisu oli pinnalla; olimme jo käyttäneet sen kaikkia elementtejä muiden ongelmien ratkaisemiseen:

  1. Jokaisen asiakkaan kohdalla meidän on tiedettävä, milloin hän näki vastustajia, kun hän painoi näppäimiä. Olemme jo kirjoittaneet nämä tiedot syöttöpakettiin ja käyttäneet niitä asiakasennusteen säätämiseen.
  2. Meidän on pystyttävä tallentamaan pelitilojen historia. Siinä me pidämme vastustajiemme (ja siten heidän törmäystensä) kannat. Meillä oli jo tilahistoria palvelimella, käytimme sitä rakentamiseen deltat. Tietämällä oikean ajan voimme helposti löytää oikean tilan historiasta.
  3. Nyt kun meillä on historian pelitila käsissämme, meidän on kyettävä synkronoimaan pelaajatiedot fyysisen maailman tilan kanssa. Olemassa olevat törmäajat - siirrä, puuttuvat - luo, tarpeettomat - tuhoa. Tämä logiikka oli myös jo kirjoitettu ja koostui useista ECS-järjestelmistä. Käytimme sitä useiden pelihuoneiden pitämiseen yhdessä Unity-prosessissa. Ja koska fyysinen maailma on yksi prosessia kohden, se oli käytettävä uudelleen huoneiden välillä. Ennen jokaista simulaation rastia "nollasimme" fyysisen maailman tilan ja alustasimme sen uudelleen nykyisen huoneen tiedoilla yrittäen käyttää Unity-peliobjekteja mahdollisimman paljon uudelleen älykkään yhdistämisjärjestelmän avulla. Jäljelle jäi vain vedota samaan pelitilaan menneisyydestä.

Laittamalla kaikki nämä elementit yhteen, saimme "aikakoneen", joka pystyi palauttamaan fyysisen maailman tilan oikeaan hetkeen. Koodi osoittautui yksinkertaiseksi:

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äljelle jäi vain selvittää, kuinka käyttää tätä konetta laukausten ja kykyjen kompensoimiseksi helposti.

Yksinkertaisimmassa tapauksessa, kun mekaniikka perustuu yhteen osumaskannaukseen, kaikki näyttää olevan selvää: ennen kuin pelaaja laukaisee, hänen on palautettava fyysinen maailma haluttuun tilaan, suoritettava raycast, laskettava osuma tai epäonnistuminen ja palauttaa maailman alkuperäiseen tilaan.

Mutta Dino Squadissa on hyvin vähän sellaisia ​​mekaniikkoja! Suurin osa pelin aseista luo ammuksia - pitkäikäisiä luoteja, jotka lentävät useiden simulaatiopukkien (joissakin tapauksissa kymmenien punkkien) luo. Mitä niille tehdä, mihin aikaan niiden pitäisi lentää?

В vanha artikkeli Half-Life-verkkopinosta Valven kaverit esittivät saman kysymyksen, ja heidän vastauksensa oli tämä: ammuksen viiveen kompensointi on ongelmallista, ja sitä on parempi välttää.

Meillä ei ollut tätä vaihtoehtoa: ammuspohjaiset aseet olivat pelin suunnittelun avainominaisuus. Joten meidän piti keksiä jotain. Pienen aivoriihen jälkeen muotoilimme kaksi vaihtoehtoa, jotka näyttivät toimivan:

1. Sidomme ammuksen sen luoneen pelaajan aikaan. Palvelinsimulaation jokainen rasti, jokaisen pelaajan jokaista luotia kohden siirrämme fyysisen maailman takaisin asiakastilaan ja suoritamme tarvittavat laskelmat. Tämä lähestymistapa mahdollisti hajautetun kuormituksen palvelimelle ja ennustettavan ammusten lentoajan. Ennustettavuus oli meille erityisen tärkeää, koska meillä on kaikki ammukset, myös vihollisen ammukset, ennustettu asiakkaaseen.

Kuinka paransimme ballististen laskelmien mekaniikkaa mobiiliampujalle verkon latenssin kompensointialgoritmilla
Kuvassa rastilla 30 oleva pelaaja laukaisee ohjuksen ennakoidessaan: hän näkee mihin suuntaan vihollinen juoksee ja tietää ohjuksen likimääräisen nopeuden. Paikallisesti hän näkee osuvansa maaliin 33. rastilla. Viivekompensoinnin ansiosta se näkyy myös palvelimella

2. Teemme kaiken samalla tavalla kuin ensimmäisessä vaihtoehdossa, mutta laskettuamme yhden luotisimulaation rastin, emme pysähdy, vaan jatkamme sen lennon simulointia saman palvelimen rastin sisällä, joka kerta tuoden sen ajan lähemmäksi palvelinta yksi kerrallaan rasti ja päivittää törmäyslaitteiden asentoja. Teemme näin, kunnes jompikumpi kahdesta asiasta tapahtuu:

  • Luoti on vanhentunut. Tämä tarkoittaa, että laskelmat ovat ohi, voimme laskea ohimennen tai osuman. Ja tämä on samassa rastissa, jossa laukaus ammuttiin! Meille tämä oli sekä plussa että miinus. Plussa - koska ampujalle tämä lyhensi merkittävästi osuman ja vihollisen terveyden heikkenemisen välistä viivettä. Huono puoli on, että sama vaikutus havaittiin, kun vastustajat ampuivat pelaajaa: vihollinen näytti ampuneen vain hitaan raketin, ja vahinko oli jo laskettu.
  • Luoti on saavuttanut palvelimen ajan. Tässä tapauksessa sen simulointi jatkuu seuraavassa palvelimessa ilman viivekompensaatiota. Hitaille ammuksille tämä voisi teoriassa vähentää fysiikan palautusten määrää verrattuna ensimmäiseen vaihtoehtoon. Samanaikaisesti simulaation epätasainen kuormitus lisääntyi: palvelin oli joko tyhjäkäynnillä tai yhdessä palvelintickissä se laski tusinaa simulaatiotikkiä usealle luodille.

Kuinka paransimme ballististen laskelmien mekaniikkaa mobiiliampujalle verkon latenssin kompensointialgoritmilla
Sama skenaario kuin edellisessä kuvassa, mutta laskettu toisen kaavion mukaan. Ohjus "takahti" palvelinaikaan samalla rastilla, kun laukaus tapahtui, ja osuma voidaan laskea jo seuraavaan tikkuun. 31. rastilla tässä tapauksessa viivekompensaatiota ei enää sovelleta

Toteutuksessamme nämä kaksi lähestymistapaa erosivat vain parilla koodirivillä, joten loimme molemmat, ja ne olivat olemassa pitkään rinnakkain. Aseen mekaniikasta ja luodin nopeudesta riippuen valitsimme kullekin dinosaurukselle yhden tai toisen vaihtoehdon. Käännekohta tässä oli ilmaantuminen mekaniikkapeleihin, kuten "jos osut viholliseen niin monta kertaa sellaisessa ja sellaisessa ajassa, saat sellaisen ja sellaisen bonuksen." Jokainen mekaanikko, jossa pelaajan osuma viholliseen oli tärkeä rooli, kieltäytyi toimimasta toisella lähestymistavalla. Joten päädyimme käyttämään ensimmäistä vaihtoehtoa, ja se koskee nyt kaikkia pelin aseita ja kaikkia aktiivisia kykyjä.

Erikseen kannattaa ottaa esille suorituskykykysymys. Jos luulit tämän kaiken hidastavan asioita, vastaan: on. Unity on melko hidas siirtämään törmäyslaitteita ja kytkemään niitä päälle ja pois. Dino Squadissa "pahimmassa" tapauksessa taistelussa voi olla samanaikaisesti useita satoja ammuksia. Törmäyskoneiden siirtäminen jokaisen ammuksen laskemiseksi erikseen on kohtuutonta luksusta. Siksi meidän oli ehdottoman välttämätöntä minimoida fysiikan "palautusten" määrä. Tätä varten loimme ECS:ään erillisen komponentin, johon tallennamme pelaajan ajan. Lisäsimme sen kaikkiin entiteeteihin, jotka vaativat viiveen kompensointia (projektiilit, kyvyt jne.). Ennen kuin aloitamme tällaisten kokonaisuuksien käsittelyn, klusteroimme ne tähän mennessä ja käsittelemme ne yhdessä, kierrättämällä fyysistä maailmaa kerran kullekin klusterille.

Tässä vaiheessa meillä on yleisesti toimiva järjestelmä. Sen koodi hieman yksinkertaistetussa muodossa:

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äljelle jäi vain yksityiskohtien konfigurointi:

1. Ymmärrä, kuinka paljon enimmäisliikeetäisyyttä on rajoitettava ajassa.

Meille oli tärkeää tehdä pelistä mahdollisimman saavutettavissa huonoissa mobiiliverkoissa, joten rajoitimme tarinaa 30 tikin marginaalilla (20 Hz:n tikkutaajuudella). Näin pelaajat voivat lyödä vastustajia jopa erittäin korkeilla pingeillä.

2. Selvitä, mitä esineitä voidaan siirtää ajassa ja mitä ei.

Me tietysti liikutamme vastustajiamme. Mutta esimerkiksi asennettavat energiasuojat eivät ole. Päätimme, että on parempi antaa etusija puolustuskyvylle, kuten verkkoammunnassa usein tehdään. Jos pelaaja on jo asettanut kilven nykyhetkeen, menneisyyden viivekompensoitujen luotien ei pitäisi lentää sen läpi.

3. Päätä, onko tarpeen kompensoida dinosaurusten kykyjä: pureminen, hännän lyöminen jne. Päätimme, mitä tarvitaan ja käsittelemme ne samojen sääntöjen mukaan kuin luoteja.

4. Määritä, mitä tehdä sen pelaajan törmäyslaitteille, jolle viivekompensointi suoritetaan. Hyvällä tavalla heidän asemansa ei saisi siirtyä menneisyyteen: pelaajan tulee nähdä itsensä samassa ajassa, jossa hän on nyt palvelimella. Peruutamme kuitenkin myös ampujan törmäyksiä, ja tähän on useita syitä.

Ensinnäkin se parantaa klusterointia: voimme käyttää samaa fyysistä tilaa kaikille pelaajille, joilla on läheiset pingit.

Toiseksi, kaikissa sädelähetyksissä ja päällekkäisyyksissä jätämme aina pois sen pelaajan törmäajat, joka omistaa kyvyt tai ammukset. Dino Squadissa pelaajat hallitsevat dinosauruksia, joiden geometria on ampujastandardien mukaan melko epätyypillinen. Vaikka pelaaja ampuisi epätavallisessa kulmassa ja luodin liikerata kulkee pelaajan dinosaurustörmäimen läpi, luoti jättää sen huomioimatta.

Kolmanneksi laskemme dinosauruksen aseen sijainnit tai kyvyn sovelluspisteet käyttämällä ECS:n tietoja jo ennen viivekompensoinnin alkamista.

Tämän seurauksena viivekompensoidun pelaajan törmäyslaitteiden todellinen sijainti ei ole meille tärkeä, joten valitsimme tuottavamman ja samalla yksinkertaisemman polun.

Verkon latenssia ei voi yksinkertaisesti poistaa, se voidaan vain peittää. Kuten kaikilla muillakin naamiointimenetelmillä, palvelimen viiveen kompensaatiolla on kompromissinsa. Se parantaa ampuvan pelaajan pelikokemusta ammuttavan pelaajan kustannuksella. Dino Squadille valinta oli kuitenkin ilmeinen.

Tietenkin tämän kaiken piti maksaa myös palvelinkoodin monimutkaisempi kokonaisuus - sekä ohjelmoijille että pelisuunnittelijoille. Jos aiemmin simulaatio oli yksinkertainen peräkkäinen järjestelmien kutsu, niin viivekompensaatiolla siihen ilmestyi sisäkkäisiä silmukoita ja haaroja. Olemme myös tehneet paljon vaivaa, jotta sen kanssa olisi mukava työskennellä.

Vuoden 2019 versiossa (ja ehkä hieman aikaisemmin) Unity lisäsi täyden tuen itsenäisille fyysisille kohtauksille. Otimme ne palvelimelle lähes välittömästi päivityksen jälkeen, koska halusimme nopeasti päästä eroon kaikkien huoneiden yhteisestä fyysisestä maailmasta.

Annoimme jokaiselle pelihuoneelle oman fyysisen kohtauksensa ja näin ollen poistimme tarpeen "poistaa" kohtaus naapurihuoneen tiedoista ennen simulaation laskemista. Ensinnäkin se lisäsi merkittävästi tuottavuutta. Toiseksi se mahdollisti kokonaisen luokan vikojen poistamisen, joita syntyi, jos ohjelmoija teki virheen kohtauksen puhdistuskoodissa lisätessään uusia pelielementtejä. Tällaisia ​​virheitä oli vaikea korjata, ja ne johtivat usein siihen, että yhden huoneen kohtauksen fyysisten kohteiden tila "virtasi" toiseen huoneeseen.

Lisäksi tutkimme, voidaanko fyysisiä kohtauksia käyttää fyysisen maailman historian tallentamiseen. Eli ei ehdollisesti osoita jokaiseen huoneeseen yhtä kohtausta, vaan 30 kohtausta ja tee niistä syklinen puskuri, johon tarina tallennetaan. Yleisesti ottaen vaihtoehto osoittautui toimivaksi, mutta emme toteuttaneet sitä: se ei osoittanut hullua tuottavuuden kasvua, mutta vaati melko riskialttiita muutoksia. Oli vaikea ennustaa, kuinka palvelin käyttäytyisi, kun työskenteli pitkään niin monen kohtauksen kanssa. Siksi noudatimme sääntöä: "Jos se ei ole rikki, älä korjaa sitä'.

Lähde: will.com

Lisää kommentti