Hogyan javítottuk a ballisztikai számítások mechanikáját egy mobil lövöldözős géphez hálózati késleltetés kompenzációs algoritmussal

Hogyan javítottuk a ballisztikai számítások mechanikáját egy mobil lövöldözős géphez hálózati késleltetés kompenzációs algoritmussal

Szia, Nikita Brizhak vagyok, a Pixonic szerverfejlesztője. Ma a mobil multiplayer játékban tapasztalható késések kompenzálásáról szeretnék beszélni.

Sok cikk született a szerver késés kompenzációjáról, beleértve az orosz nyelvet is. Ez nem meglepő, mivel ezt a technológiát a 90-es évek vége óta aktívan használják a többjátékos FPS létrehozásában. Emlékezhet például a QuakeWorld modra, amely az elsők között használta.

Használjuk a Dino Squad mobil többjátékos lövöldözős játékunkban is.

Ebben a cikkben nem az a célom, hogy megismételjem a már ezerszer leírtakat, hanem az, hogy elmondjam, hogyan valósítottuk meg a játékunkban a késéskompenzációt, figyelembe véve a technológiai veremünket és az alapvető játékmeneti jellemzőinket.

Néhány szó a kéregünkről és a technológiánkról.

A Dino Squad egy hálózati mobil PvP lövöldözős játék. A játékosok különféle fegyverekkel felszerelt dinoszauruszokat irányítanak, és 6v6 csapatokban küzdenek meg egymással.

Mind a kliens, mind a szerver Unity alapú. Az architektúra meglehetősen klasszikus a lövöldözősök számára: a szerver tekintélyelvű, és a kliensek előrejelzése működik a klienseken. A játékszimulációt házon belüli ECS-sel írják, és a szerveren és a kliensen is használják.

Ha most először hall a késés kompenzációjáról, íme egy rövid ismertető a problémáról.

A többjátékos FPS játékokban a meccset általában egy távoli szerveren szimulálják. A játékosok elküldik a bemenetüket (információkat a lenyomott billentyűkről) a szervernek, válaszul pedig a szerver frissített játékállapotot küld nekik a kapott adatok figyelembevételével. Ezzel az interakciós sémával a késleltetés az előre billentyű lenyomása és a játékos karakter képernyőn való mozgásának pillanata között mindig nagyobb lesz, mint a ping.

Míg a helyi hálózatokon ez a késleltetés (népszerű nevén input lag) észrevehetetlen lehet, addig az interneten keresztüli játék során a „jégen csúszás” érzését kelti a karakter irányításakor. Ez a probléma kétszeresen is lényeges a mobilhálózatok esetében, ahol még mindig kiváló kapcsolatnak számít az az eset, amikor egy játékos pingje 200 ms. A ping gyakran 350, 500 vagy 1000 ms lehet. Ezután szinte lehetetlenné válik egy gyors lövöldözős játék beviteli késleltetéssel.

A probléma megoldása az ügyféloldali szimulációs előrejelzés. Itt a kliens maga alkalmazza a bemenetet a játékos karakterére, anélkül, hogy megvárná a szerver válaszát. És amikor megérkezik a válasz, egyszerűen összehasonlítja az eredményeket, és frissíti az ellenfelek pozícióit. Ebben az esetben minimális a késleltetés a gomb megnyomása és az eredmény képernyőn való megjelenítése között.

Itt fontos megérteni az árnyalatot: a kliens mindig az utolsó bemenete szerint rajzolja meg magát, az ellenségeket pedig - hálózati késéssel, az előző állapot szerint a szerver adataiból. Vagyis amikor egy ellenségre lő, a játékos a múltban látja őt önmagához képest. További információ az ügyfél előrejelzéséről írtunk korábban.

Így a kliens előrejelzés megold egy problémát, de létrehoz egy másikat: ha egy játékos arra a pontra lő, ahol a múltban az ellenség volt, a szerveren, amikor ugyanarra a pontra lő, akkor előfordulhat, hogy az ellenség már nincs ezen a helyen. A szerver késés kompenzációja megpróbálja megoldani ezt a problémát. Amikor egy fegyver elsül, a szerver visszaállítja azt a játékállapotot, amelyet a játékos a lövéskor helyileg látott, és ellenőrzi, hogy valóban eltalálta-e az ellenséget. Ha a válasz „igen”, a találatot a rendszer számolja, még akkor is, ha az ellenség azon a ponton már nincs a szerveren.

Ezzel a tudással felvértezve megkezdtük a szerver késés kompenzációjának megvalósítását a Dino Squadban. Először is meg kellett értenünk, hogyan lehet visszaállítani a szerveren azt, amit a kliens látott? És pontosan mit kell helyreállítani? A mi játékunkban a fegyverekből és képességekből származó találatokat sugársugárzással és átfedésekkel számítjuk ki – vagyis az ellenség fizikai ütközőivel való interakciókon keresztül. Ennek megfelelően ezeknek az ütközőknek a pozícióját kellett reprodukálnunk, amit a játékos helyben „látott” a szerveren. Akkoriban a Unity 2018.x verzióját használtuk. A fizikai API ott statikus, a fizikai világ egyetlen példányban létezik. Nincs mód az állapotának mentésére, majd visszaállítására a dobozból. Szóval mit kéne tenni?

A megoldás a felszínen volt, minden elemét már felhasználtuk más problémák megoldására:

  1. Minden ügyfélnél tudnunk kell, hogy mikor látott ellenfelet, amikor megnyomta a billentyűket. Ezeket az információkat már beírtuk a beviteli csomagba, és felhasználtuk az ügyfél előrejelzésének beállításához.
  2. Tudnunk kell tárolni a játékállapotok történetét. Ebben fogjuk megtartani ellenfeleink (és így ütközőik) pozícióit. Volt már állapottörténetünk a szerveren, azt használtuk az építkezéshez delták. A megfelelő időpont ismeretében könnyen megtalálhatnánk a megfelelő állapotot a történelemben.
  3. Most, hogy kezünkben van a történelem játékállapota, tudnunk kell szinkronizálni a játékosok adatait a fizikai világ állapotával. Meglévő ütközők - mozgatni, hiányzók - létrehozni, feleslegesek - megsemmisíteni. Ez a logika szintén meg volt írva, és több ECS rendszerből állt. Több játékterem megtartására használtuk egy Unity folyamatban. És mivel a fizikai világ folyamatonként egy, azt újra fel kellett használni a szobák között. A szimuláció minden egyes kipipálása előtt "visszaállítottuk" a fizikai világ állapotát, és újra inicializáltuk az aktuális szoba adataival, megpróbálva a Unity játékobjektumokat a lehető legtöbbször újra felhasználni egy okos pooling rendszeren keresztül. Nem maradt más hátra, mint ugyanazt a logikát hivatkozni a múltbeli játékállapotra.

Mindezeket az elemeket összerakva egy „időgépet” kaptunk, amely a fizikai világ állapotát a megfelelő pillanatra tudja visszaforgatni. A kód egyszerűnek bizonyult:

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

Nem maradt más hátra, mint kitalálni, hogyan lehet ezzel a géppel könnyen kompenzálni a lövéseket és a képességeket.

A legegyszerűbb esetben, amikor a mechanika egyetlen találati beolvasáson alapul, minden világosnak tűnik: mielőtt a játékos lő, vissza kell állítania a fizikai világot a kívánt állapotba, raycastot kell végeznie, meg kell számolnia a találatot vagy kihagyást, és visszaállítani a világot a kezdeti állapotba.

De nagyon kevés ilyen szerelő van a Dino Squadban! A legtöbb fegyver a játékban lövedékeket hoz létre – hosszú életű golyókat, amelyek több szimulációs kullancsért (egyes esetekben több tucat kullancsért) repülnek. Mit csináljunk velük, mikor repüljenek?

В ősi cikk a Half-Life hálózati veremről ugyanezt a kérdést tették fel a Valve srácai, a válaszuk ez volt: a lövedékkésés kompenzációja problémás, és jobb elkerülni.

Nem volt lehetőségünk erre: a lövedék alapú fegyverek a játék tervezésének kulcsfontosságú elemei voltak. Tehát ki kellett találnunk valamit. Némi ötletelés után két olyan lehetőséget fogalmaztunk meg, amelyek működni látszottak:

1. A lövedéket annak a játékosnak az idejéhez kötjük, aki létrehozta. A szerverszimuláció minden pipáját, minden játékos minden egyes golyójára visszaforgatjuk a fizikai világot a kliens állapotba, és elvégezzük a szükséges számításokat. Ez a megközelítés lehetővé tette a szerver megosztott terhelését és a lövedékek előrelátható repülési idejét. A kiszámíthatóság különösen fontos volt számunkra, mivel minden lövedéket, beleértve az ellenséges lövedékeket is, előre jeleztük az ügyfélre.

Hogyan javítottuk a ballisztikai számítások mechanikáját egy mobil lövöldözős géphez hálózati késleltetés kompenzációs algoritmussal
A képen a 30-as jelölőnél lévő játékos előrelátóan lő ki egy rakétát: látja, hogy az ellenség melyik irányba fut, és tudja a rakéta hozzávetőleges sebességét. Helyileg azt látja, hogy a 33. ticknél találta el a célt. A lag kompenzációnak köszönhetően a szerveren is megjelenik

2. Mindent ugyanúgy csinálunk, mint az első opciónál, de miután megszámoltuk a golyó szimuláció egy pipáját, nem állunk meg, hanem folytatjuk a repülés szimulálását ugyanazon a szerveren belül, minden alkalommal közelebb hozva az időt a szerverhez egyenként pipálja ki és frissíti az ütközők pozícióit. Ezt addig tesszük, amíg a két dolog egyike meg nem történik:

  • A golyó lejárt. Ez azt jelenti, hogy a számításoknak vége, számolhatunk kihagyást vagy találatot. És ez ugyanazon a pipán van, amelyben a lövés eldördült! Számunkra ez plusz és mínusz is volt. Plusz - mert a lövöldözős játékos számára ez jelentősen csökkentette a késést a találat és az ellenség egészségi állapotának csökkenése között. Hátránya, hogy ugyanaz a hatás volt megfigyelhető, amikor az ellenfelek lőttek a játékosra: az ellenség, úgy tűnik, csak egy lassú rakétát lőtt ki, és a sebzést már megszámolták.
  • A golyó elérte a szerveridőt. Ebben az esetben a szimuláció a következő szerver tickben folytatódik, minden késedelem kompenzáció nélkül. Lassú lövedékeknél ez elméletileg csökkentheti a fizikai visszagörgetések számát az első lehetőséghez képest. Ezzel párhuzamosan nőtt a szimuláció egyenetlen terhelése: a szerver vagy tétlen volt, vagy egy szerver ticknél tucatnyi szimulációs ticket számolt több golyóra.

Hogyan javítottuk a ballisztikai számítások mechanikáját egy mobil lövöldözős géphez hálózati késleltetés kompenzációs algoritmussal
Ugyanaz a forgatókönyv, mint az előző képen, de a második séma szerint számítva. A rakéta a lövés idején „utolérte” a szerveridőt, és a találat már a következő ticknél számolható. A 31. ticknél ebben az esetben a késéskompenzáció már nem érvényesül

A mi megvalósításunkban ez a két megközelítés mindössze néhány sornyi kódban különbözött egymástól, így mindkettőt létrehoztuk, és sokáig párhuzamosan léteztek. A fegyver mechanikájától és a golyó sebességétől függően minden dinoszauruszhoz választottunk egy vagy másik lehetőséget. A fordulópont itt a mechanika játékában való megjelenés volt, mint például „ha annyiszor eltalálod az ellenséget ilyen és ilyen időben, kapj ilyen és olyan bónuszt”. Bármely szerelő, akinél fontos szerepet játszott az az időpont, amikor a játékos eltalálta az ellenséget, nem volt hajlandó dolgozni a második megközelítéssel. Így végül az első opció mellett döntöttünk, és ez most a játék összes fegyverére és minden aktív képességére vonatkozik.

Külön érdemes felvetni a teljesítmény kérdését. Ha azt gondoltad, hogy mindez lelassítja a dolgokat, azt válaszolom: így van. A Unity meglehetősen lassan mozgatja az ütközőket és kapcsolja be és ki. A Dino Squadban a "legrosszabb" forgatókönyv szerint több száz lövedék is létezhet egyidejűleg a harcban. Az ütköztetők mozgatása az egyes lövedékek egyenkénti megszámlálására megfizethetetlen luxus. Ezért feltétlenül szükséges volt, hogy minimalizáljuk a fizika „visszaforgatások” számát. Ehhez az ECS-ben külön komponenst készítettünk, amelyben rögzítjük a játékos idejét. Hozzáadtuk minden olyan entitáshoz, amely késéskompenzációt igényel (lövedékek, képességek stb.). Mielőtt elkezdenénk feldolgozni az ilyen entitásokat, addigra klaszterezzük őket, és együtt dolgozzuk fel, minden klaszter esetében egyszer visszagörgetjük a fizikai világot.

Ebben a szakaszban egy általánosan működő rendszerünk van. A kódja kissé leegyszerűsített formában:

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

Már csak a részletek konfigurálása maradt hátra:

1. Értse meg, mennyivel korlátozza időben a maximális mozgási távolságot.

Számunkra fontos volt, hogy a játékot a lehető legelérhetőbbé tegyük rossz mobilhálózatok körülményei között, ezért 30 pipával (20 Hz-es tickaránnyal) korlátoztuk a történetet. Ez lehetővé teszi a játékosoknak, hogy még nagyon magas pingeknél is eltalálják az ellenfelet.

2. Határozza meg, mely tárgyak mozgathatók időben és melyek nem!

Természetesen megmozgatjuk ellenfeleinket. De például a telepíthető energiapajzsok nem. Úgy döntöttünk, hogy jobb, ha a védekező képességet részesítjük előnyben, ahogy az az online lövöldözős játékokban gyakran történik. Ha a játékos már elhelyezett egy pajzsot a jelenben, akkor a múltból származó késleltetéskompenzált golyók nem repülhetnek át rajta.

3. Döntse el, hogy szükséges-e kompenzálni a dinoszauruszok képességeit: harapás, farokütés, stb. Eldöntöttük, hogy mire van szükség, és ugyanazon szabályok szerint dolgozzuk fel, mint a golyókat.

4. Határozza meg, mi a teendő annak a játékosnak az ütközőivel, akinek a késés kompenzációját végzik. Jó értelemben a pozíciójuk nem tolódik el a múltba: a játékosnak ugyanabban az időben kell látnia magát, amelyben most a szerveren van. Viszont a lövöldözős játékos ütközőit is visszagurítjuk, ennek több oka is van.

Először is javítja a klaszterezést: ugyanazt a fizikai állapotot használhatjuk minden közeli ping esetén.

Másodszor, minden raycast és átfedés esetén mindig kizárjuk annak a játékosnak az ütközőit, aki birtokolja a képességeket vagy lövedékeket. A Dino Squadban a játékosok dinoszauruszokat irányítanak, amelyek geometriája meglehetősen nem szabványos a lövöldözős szabványok szerint. Még ha a játékos szokatlan szögben lő, és a golyó röppályája áthalad a játékos dinoszauruszütközőjén, a golyó figyelmen kívül hagyja azt.

Harmadszor, az ECS-ből származó adatok alapján kiszámítjuk a dinoszaurusz fegyverének pozícióit vagy a képesség alkalmazási pontját, még a késéskompenzáció megkezdése előtt.

Ebből kifolyólag a lemaradáskompenzált játékos ütközőinek valós helyzete számunkra nem fontos, így egy eredményesebb és egyben egyszerűbb utat választottunk.

A hálózati késleltetést nem lehet egyszerűen eltávolítani, csak elfedni. Mint minden más álcázási módszernek, a szerverkésés-kompenzációnak is megvannak a maga kompromisszumai. Javítja a lövöldöző játékos játékélményét a rálőtt játékos rovására. A Dino Squad számára azonban nyilvánvaló volt a választás.

Mindezt természetesen a szerverkód egészének megnövekedett bonyolultságával is meg kellett fizetni - mind a programozóknak, mind a játéktervezőknek. Ha korábban a szimuláció egyszerű szekvenciális rendszerek hívása volt, akkor lag kompenzációval egymásba ágyazott hurkok és ágak jelentek meg benne. Sokat törekedtünk arra is, hogy kényelmes legyen vele dolgozni.

A 2019-es verzióban (és talán egy kicsit korábban) a Unity teljes mértékben támogatja a független fizikai jeleneteket. A frissítés után szinte azonnal implementáltuk őket a szerverre, mert gyorsan meg akartunk szabadulni a minden helyiségben megszokott fizikai világtól.

Minden játékteremnek saját fizikai jelenetet adtunk, így a szimuláció kiszámítása előtt nem kellett „törölni” a jelenetet a szomszédos szoba adataiból. Először is, jelentősen növelte a termelékenységet. Másodszor, lehetővé tette a hibák egy egész osztályának megszabadulását, amelyek akkor keletkeztek, ha a programozó hibát vétett a jelenettisztító kódban új játékelemek hozzáadásakor. Az ilyen hibákat nehéz volt kiküszöbölni, és gyakran azt eredményezték, hogy az egyik helyiségben lévő fizikai tárgyak állapota „áramlik” a másik szobába.

Ezen kívül néhány kutatást végeztünk arra vonatkozóan, hogy a fizikai jelenetek felhasználhatók-e a fizikai világ történetének tárolására. Vagyis feltételesen ne egy jelenetet rendeljünk minden helyiséghez, hanem 30 jelenetet, és készítsünk belőlük ciklikus puffert, amelyben eltároljuk a történetet. Általánosságban elmondható, hogy az opció bevált, de nem hajtottuk végre: nem mutatott őrült termelékenységnövekedést, de meglehetősen kockázatos változtatásokat igényelt. Nehéz volt megjósolni, hogyan fog viselkedni a szerver, ha hosszú ideig dolgozik ennyi jelenettel. Ezért követtük a szabályt: „Ha nem tört meg, ne javítsd meg".

Forrás: will.com

Hozzászólás