Hvordan vi forbedret mekanikken til ballistiske beregninger for et mobilt skytespill med en kompensasjonsalgoritme for nettverksforsinkelse

Hvordan vi forbedret mekanikken til ballistiske beregninger for et mobilt skytespill med en kompensasjonsalgoritme for nettverksforsinkelse

Hei, jeg heter Nikita Brizhak, en serverutvikler fra Pixonic. I dag vil jeg gjerne snakke om å kompensere for etterslep i mobil flerspiller.

Det er skrevet mange artikler om serverforsinkelseskompensasjon, inkludert på russisk. Dette er ikke overraskende, siden denne teknologien har blitt aktivt brukt i etableringen av flerspiller FPS siden slutten av 90-tallet. For eksempel kan du huske QuakeWorld-moden, som var en av de første som brukte den.

Vi bruker den også i vårt mobile flerspillerskytespill Dino Squad.

I denne artikkelen er ikke målet mitt å gjenta det som allerede er skrevet tusen ganger, men å fortelle hvordan vi implementerte lagkompensasjon i spillet vårt, tatt i betraktning vår teknologistabel og kjernefunksjoner i spillet.

Noen få ord om vår cortex og teknologi.

Dino Squad er et nettverksmobilt PvP-skytespill. Spillere kontrollerer dinosaurer utstyrt med en rekke våpen og kjemper mot hverandre i 6v6-lag.

Både klienten og serveren er basert på Unity. Arkitekturen er ganske klassisk for skytespill: serveren er autoritær, og klientprediksjon fungerer på klientene. Spillsimuleringen er skrevet ved hjelp av intern ECS og brukes på både server og klient.

Hvis dette er første gang du har hørt om etterslep-kompensasjon, her er en kort utflukt til problemet.

I flerspiller FPS-spill simuleres kampen vanligvis på en ekstern server. Spillere sender sine input (informasjon om tastene som trykkes) til serveren, og som svar sender serveren dem en oppdatert spillstatus som tar hensyn til de mottatte dataene. Med dette interaksjonsskjemaet vil forsinkelsen mellom å trykke frem-tasten og øyeblikket spillerkarakteren beveger seg på skjermen alltid være større enn ping.

Mens på lokale nettverk kan denne forsinkelsen (populært kalt input lag) være umerkelig, når du spiller via Internett, skaper den en følelse av å "gli på isen" når du kontrollerer en karakter. Dette problemet er dobbelt relevant for mobilnettverk, der tilfellet når en spillers ping er 200 ms fortsatt anses som en utmerket forbindelse. Ofte kan pinget være 350, 500 eller 1000 ms. Da blir det nesten umulig å spille et raskt skytespill med input lag.

Løsningen på dette problemet er simuleringsprediksjon på klientsiden. Her bruker klienten selv input til spillerkarakteren, uten å vente på svar fra serveren. Og når svaret er mottatt, sammenligner den ganske enkelt resultatene og oppdaterer motstandernes posisjoner. Forsinkelsen mellom å trykke på en tast og vise resultatet på skjermen er i dette tilfellet minimal.

Det er viktig å forstå nyansen her: klienten tegner alltid seg selv i henhold til siste input, og fiender - med nettverksforsinkelse, i henhold til forrige tilstand fra dataene fra serveren. Det vil si at når han skyter mot en fiende, ser spilleren ham i fortiden i forhold til seg selv. Mer om klientprediksjon vi skrev tidligere.

Dermed løser klientprediksjon ett problem, men skaper et annet: hvis en spiller skyter på punktet der fienden var i fortiden, på serveren når han skyter på samme punkt, kan det hende at fienden ikke lenger er på det stedet. Serverforsinkelseskompensasjon forsøker å løse dette problemet. Når et våpen avfyres, gjenoppretter serveren spilltilstanden som spilleren så lokalt på tidspunktet for skuddet, og sjekker om han virkelig kunne ha truffet fienden. Hvis svaret er "ja", telles treffet, selv om fienden ikke lenger er på serveren på det tidspunktet.

Bevæpnet med denne kunnskapen begynte vi å implementere serverforsinkelseskompensasjon i Dino Squad. Først av alt måtte vi forstå hvordan vi gjenoppretter det klienten så på serveren? Og hva skal egentlig gjenopprettes? I spillet vårt beregnes treff fra våpen og evner gjennom strålekastninger og overlegg - det vil si gjennom interaksjoner med fiendens fysiske kolliderere. Følgelig trengte vi å reprodusere posisjonen til disse kollidererne, som spilleren "så" lokalt, på serveren. På den tiden brukte vi Unity versjon 2018.x. Fysikk-APIet der er statisk, den fysiske verden eksisterer i en enkelt kopi. Det er ingen måte å lagre dens tilstand og deretter gjenopprette den fra boksen. Så, hva gjør vi?

Løsningen var på overflaten; alle dens elementer hadde allerede blitt brukt av oss til å løse andre problemer:

  1. For hver klient må vi vite når han så motstandere når han trykket på tastene. Vi har allerede skrevet denne informasjonen inn i inndatapakken og brukt den til å justere klientprediksjonen.
  2. Vi må være i stand til å lagre historien til spilltilstander. Det er i den vi vil holde posisjonene til våre motstandere (og derfor deres kolliderere). Vi hadde allerede en tilstandshistorikk på serveren, vi brukte den til å bygge deltaer. Når vi vet det rette tidspunktet, kan vi lett finne den riktige tilstanden i historien.
  3. Nå som vi har spillets tilstand fra historien i hånden, må vi være i stand til å synkronisere spillerdata med tilstanden til den fysiske verden. Eksisterende kollidere - flytt, manglende - skap, unødvendig - ødelegge. Denne logikken var også allerede skrevet og besto av flere ECS-systemer. Vi brukte den til å holde flere spillrom i én Unity-prosess. Og siden den fysiske verden er én per prosess, måtte den gjenbrukes mellom rom. Før hver hake av simuleringen "tilbakestilte" vi tilstanden til den fysiske verden og reinitialiserte den med data for det gjeldende rommet, og prøver å gjenbruke Unity-spillobjekter så mye som mulig gjennom et smart pooling-system. Alt som gjensto var å påkalle den samme logikken for spilltilstanden fra fortiden.

Ved å sette alle disse elementene sammen, fikk vi en "tidsmaskin" som kunne rulle tilbake tilstanden til den fysiske verden til riktig øyeblikk. Koden viste seg å være enkel:

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

Alt som gjensto var å finne ut hvordan du bruker denne maskinen for enkelt å kompensere for skudd og evner.

I det enkleste tilfellet, når mekanikken er basert på en enkelt treffskanning, ser alt ut til å være klart: før spilleren skyter, må han rulle tilbake den fysiske verden til ønsket tilstand, gjøre en raycast, telle treffet eller bommet, og returnere verden til den opprinnelige tilstanden.

Men det er veldig få slike mekanikere i Dino Squad! De fleste av våpnene i spillet lager prosjektiler - kuler med lang levetid som flyr for flere simuleringsflått (i noen tilfeller dusinvis av flått). Hva skal de gjøre med dem, når skal de fly?

В eldgammel artikkel om Half-Life-nettverksstakken stilte gutta fra Valve det samme spørsmålet, og svaret deres var dette: kompensasjon for prosjektiletterslep er problematisk, og det er bedre å unngå det.

Vi hadde ikke dette alternativet: prosjektilbaserte våpen var en nøkkelfunksjon i spilldesignet. Så vi måtte finne på noe. Etter litt brainstorming formulerte vi to alternativer som så ut til å fungere:

1. Vi knytter prosjektilet til tiden til spilleren som opprettet det. Hvert tikk av serversimuleringen, for hver kule av hver spiller, ruller vi tilbake den fysiske verden til klienttilstanden og utfører de nødvendige beregningene. Denne tilnærmingen gjorde det mulig å ha en fordelt belastning på serveren og forutsigbar flytid for prosjektiler. Forutsigbarhet var spesielt viktig for oss, siden vi har alle prosjektiler, inkludert fiendtlige prosjektiler, forutsagt på klienten.

Hvordan vi forbedret mekanikken til ballistiske beregninger for et mobilt skytespill med en kompensasjonsalgoritme for nettverksforsinkelse
På bildet skyter spilleren ved hake 30 et missil i påvente: han ser i hvilken retning fienden løper og vet omtrentlig hastighet på missilet. Lokalt ser han at han traff målet ved den 33. haken. Takket være forsinkelseskompensasjon vil den også vises på serveren

2. Vi gjør alt på samme måte som i det første alternativet, men etter å ha telt ett kryss i kulesimuleringen, stopper vi ikke, men fortsetter å simulere flyreisen innenfor samme servermerke, hver gang vi bringer tiden nærmere serveren én etter én hake og oppdatering av kolliderposisjoner. Vi gjør dette til en av to ting skjer:

  • Kulen er utløpt. Det betyr at utregningene er over, vi kan telle en glipp eller et treff. Og dette er ved samme hake som skuddet ble avfyrt i! For oss var dette både et pluss og et minus. Et pluss - fordi for skytespilleren reduserte dette forsinkelsen mellom treffet og nedgangen i fiendens helse betydelig. Ulempen er at den samme effekten ble observert når motstandere skjøt mot spilleren: fienden, ser det ut til, bare avfyrte en sakte rakett, og skaden var allerede regnet.
  • Kulen har nådd servertid. I dette tilfellet vil simuleringen fortsette i neste servermerke uten forsinkelseskompensasjon. For sakte prosjektiler kan dette teoretisk redusere antall fysikktilbakeføringer sammenlignet med det første alternativet. Samtidig økte den ujevne belastningen på simuleringen: serveren var enten inaktiv, eller i en server tikk beregnet den et dusin simuleringsmerker for flere kuler.

Hvordan vi forbedret mekanikken til ballistiske beregninger for et mobilt skytespill med en kompensasjonsalgoritme for nettverksforsinkelse
Samme scenario som i forrige bilde, men beregnet etter det andre opplegget. Missilet "fanget" opp servertiden ved samme hake som skuddet skjedde, og treffet kan telles så tidlig som neste hake. Ved det 31. krysset, i dette tilfellet, brukes ikke lenger etterslepkompensasjon

I implementeringen vår skilte disse to tilnærmingene seg på bare et par linjer med kode, så vi opprettet begge, og i lang tid eksisterte de parallelt. Avhengig av våpenets mekanikk og kulens hastighet, valgte vi et eller annet alternativ for hver dinosaur. Vendepunktet her var utseendet i spillet av mekanikk som "hvis du treffer fienden så mange ganger i en slik og en tid, få en slik og en bonus." Enhver mekaniker der tidspunktet da spilleren traff fienden spilte en viktig rolle, nektet å jobbe med den andre tilnærmingen. Så vi endte opp med å gå med det første alternativet, og det gjelder nå for alle våpen og alle aktive evner i spillet.

Separat er det verdt å ta opp spørsmålet om ytelse. Hvis du trodde at alt dette ville bremse ting, svarer jeg: det er det. Unity er ganske treg med å flytte kollidere og slå dem av og på. I Dino Squad kan det i «verste» tilfelle være flere hundre prosjektiler som eksisterer samtidig i kamp. Å flytte kollidere for å telle hvert prosjektil individuelt er en uoverkommelig luksus. Derfor var det absolutt nødvendig for oss å minimere antallet fysikk-"tilbakeføringer". For å gjøre dette laget vi en egen komponent i ECS der vi registrerer spillerens tid. Vi la det til alle enheter som krever etterslepkompensasjon (prosjektiler, evner, etc.). Før vi begynner å behandle slike enheter, grupperer vi dem på dette tidspunktet og behandler dem sammen, og ruller tilbake den fysiske verden én gang for hver klynge.

På dette stadiet har vi et generelt fungerende system. Koden i en noe forenklet form:

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

Alt som gjensto var å konfigurere detaljene:

1. Forstå hvor mye du skal begrense maksimal bevegelsesavstand i tid.

Det var viktig for oss å gjøre spillet så tilgjengelig som mulig under forhold med dårlige mobilnettverk, så vi begrenset historien med en margin på 30 tick (med en tick rate på 20 Hz). Dette lar spillere treffe motstandere selv ved svært høye ping.

2. Bestem hvilke objekter som kan flyttes i tid og hvilke som ikke kan.

Vi flytter selvfølgelig motstanderne våre. Men installerbare energiskjold er det for eksempel ikke. Vi bestemte oss for at det var bedre å prioritere den defensive evnen, slik det ofte gjøres i online skytespill. Hvis spilleren allerede har plassert et skjold i nåtiden, bør ikke etterslep-kompenserte kuler fra fortiden fly gjennom det.

3. Bestem om det er nødvendig å kompensere for dinosaurenes evner: bitt, haleslag osv. Vi bestemte hva som var nødvendig og behandler dem etter samme regler som kuler.

4. Bestem hva du skal gjøre med kolliderene til spilleren som forsinkelseskompensasjon utføres for. På en god måte bør deres posisjon ikke skifte til fortiden: spilleren bør se seg selv i samme tid som han nå er på serveren. Vi ruller imidlertid også tilbake kolliderene til den skytende spilleren, og det er flere grunner til dette.

For det første forbedrer det klynging: vi kan bruke den samme fysiske tilstanden for alle spillere med nære ping.

For det andre, i alle strålekastninger og overlappinger ekskluderer vi alltid kolliderene til spilleren som eier evnene eller prosjektilene. I Dino Squad kontrollerer spillere dinosaurer, som har en ganske ustandard geometri etter skytterstandarder. Selv om spilleren skyter i en uvanlig vinkel og kulens bane går gjennom spillerens dinosaurkolliderer, vil kulen ignorere den.

For det tredje beregner vi posisjonene til dinosaurens våpen eller anvendelsespunktet for evnen ved å bruke data fra ECS selv før starten av forsinkelseskompensasjon.

Som et resultat er den virkelige posisjonen til kolliderene til den etterslep-kompenserte spilleren uviktig for oss, så vi tok en mer produktiv og samtidig enklere vei.

Nettverksforsinkelse kan ikke bare fjernes, den kan bare maskeres. Som enhver annen metode for forkledning, har serverforsinkelseskompensasjon sine avveininger. Det forbedrer spillopplevelsen til spilleren som skyter på bekostning av spilleren som blir skutt på. For Dino Squad var imidlertid valget her åpenbart.

Alt dette måtte selvfølgelig også betales av den økte kompleksiteten til serverkoden som helhet – både for programmerere og spilldesignere. Hvis simuleringen tidligere var et enkelt sekvensielt kall av systemer, dukket det opp nestede løkker og grener med etterslepkompensasjon. Vi brukte også mye krefter på å gjøre det praktisk å jobbe med.

I 2019-versjonen (og kanskje litt tidligere) la Unity til full støtte for uavhengige fysiske scener. Vi implementerte dem på serveren nesten umiddelbart etter oppdateringen, fordi vi raskt ønsket å kvitte oss med den fysiske verdenen som er felles for alle rom.

Vi ga hvert spillrom sin egen fysiske scene og eliminerte dermed behovet for å "tømme" scenen fra dataene til naborommet før vi beregnet simuleringen. For det første ga det en betydelig økning i produktiviteten. For det andre gjorde det det mulig å bli kvitt en hel klasse med feil som oppsto hvis programmereren gjorde en feil i sceneoppryddingskoden når han la til nye spillelementer. Slike feil var vanskelige å feilsøke, og de resulterte ofte i at tilstanden til fysiske objekter i ett roms scene "flyt" inn i et annet rom.

I tillegg forsket vi litt på om fysiske scener kunne brukes til å lagre historien til den fysiske verden. Det vil si, betinget, tildel ikke én scene til hvert rom, men 30 scener, og lag en syklisk buffer ut av dem, der du kan lagre historien. Generelt viste alternativet seg å fungere, men vi implementerte det ikke: det viste ingen vanvittig økning i produktiviteten, men krevde ganske risikable endringer. Det var vanskelig å forutsi hvordan serveren ville oppføre seg når man jobbet lenge med så mange scener. Derfor fulgte vi regelen: "Hvis det ikke er ødelagt, må du ikke fikse det'.

Kilde: www.habr.com

Legg til en kommentar