Hvordan vi lavede mekanikken til ballistisk beregning til et mobilt skydespil med en netværksforsinkelseskompensationsalgoritme

Hvordan vi lavede mekanikken til ballistisk beregning til et mobilt skydespil med en netværksforsinkelseskompensationsalgoritme

Hej, jeg hedder Nikita Brizhak, en serverudvikler fra Pixonic. I dag vil jeg gerne tale om at kompensere for forsinkelse i mobil multiplayer.

Der er skrevet mange artikler om serverforsinkelseskompensation, herunder på russisk. Dette er ikke overraskende, da denne teknologi er blevet aktivt brugt i skabelsen af ​​multiplayer FPS siden slutningen af ​​90'erne. For eksempel kan du huske QuakeWorld mod, som var en af ​​de første til at bruge den.

Vi bruger det også i vores mobile multiplayer-skydespil Dino Squad.

I denne artikel er mit mål ikke at gentage, hvad der allerede er skrevet tusinde gange, men at fortælle, hvordan vi implementerede forsinkelseskompensation i vores spil, under hensyntagen til vores teknologistak og kerne-gameplay-funktioner.

Et par ord om vores cortex og teknologi.

Dino Squad er et netværksmobilt PvP-skydespil. Spillere kontrollerer dinosaurer udstyret med en række forskellige våben og kæmper mod hinanden i 6-6-hold.

Både klienten og serveren er baseret på Unity. Arkitekturen er ret klassisk for skydespil: Serveren er autoritær, og klientforudsigelse virker på klienterne. Spilsimuleringen er skrevet ved hjælp af in-house ECS og bruges på både server og klient.

Hvis det er første gang, du har hørt om forsinkelseskompensation, er her en kort udflugt til problemet.

I multiplayer FPS-spil simuleres kampen normalt på en ekstern server. Spillere sender deres input (information om tasterne, der trykkes) til serveren, og som svar sender serveren dem en opdateret spiltilstand under hensyntagen til de modtagne data. Med dette interaktionsskema vil forsinkelsen mellem at trykke på frem-tasten og det øjeblik, spillerkarakteren bevæger sig på skærmen, altid være større end pinget.

På lokale netværk kan denne forsinkelse (populært kaldet inputlag) være umærkelig, når man spiller via internettet, skaber det en følelse af at "glide på is", når man kontrollerer en karakter. Dette problem er dobbelt relevant for mobilnetværk, hvor tilfældet, når en spillers ping er 200 ms, stadig betragtes som en fremragende forbindelse. Ofte kan ping være 350, 500 eller 1000 ms. Så bliver det næsten umuligt at spille et hurtigt skydespil med input lag.

Løsningen på dette problem er simuleringsforudsigelse på klientsiden. Her anvender klienten selv input til spillerkarakteren uden at vente på et svar fra serveren. Og når svaret er modtaget, sammenligner den blot resultaterne og opdaterer modstandernes positioner. Forsinkelsen mellem tryk på en tast og visning af resultatet på skærmen er i dette tilfælde minimal.

Det er vigtigt at forstå nuancen her: klienten tegner altid sig selv i henhold til dets sidste input, og fjender - med netværksforsinkelse, i henhold til den tidligere tilstand fra dataene fra serveren. Det vil sige, at når han skyder på en fjende, ser spilleren ham i fortiden i forhold til sig selv. Mere om klientforudsigelse vi skrev tidligere.

Klientforudsigelse løser således ét problem, men skaber et andet: Hvis en spiller skyder på det punkt, hvor fjenden var i fortiden, på serveren, når han skyder på samme punkt, er fjenden muligvis ikke længere på det sted. Serverforsinkelseskompensation forsøger at løse dette problem. Når et våben affyres, genopretter serveren den spiltilstand, som spilleren så lokalt på tidspunktet for skuddet, og tjekker, om han virkelig kunne have ramt fjenden. Hvis svaret er "ja", tælles hit, selvom fjenden ikke længere er på serveren på det tidspunkt.

Bevæbnet med denne viden begyndte vi at implementere serverforsinkelseskompensation i Dino Squad. Først og fremmest skulle vi forstå, hvordan vi gendanner det, klienten så på serveren? Og hvad skal der præcist genoprettes? I vores spil beregnes hits fra våben og evner gennem raycasts og overlays - det vil sige gennem interaktioner med fjendens fysiske kolliderere. Derfor var vi nødt til at gengive positionen af ​​disse kollidere, som spilleren "så" lokalt, på serveren. På det tidspunkt brugte vi Unity version 2018.x. Fysik API der er statisk, den fysiske verden eksisterer i en enkelt kopi. Der er ingen måde at gemme dens tilstand og derefter gendanne den fra boksen. Så hvad skal man gøre?

Løsningen var på overfladen; alle dens elementer var allerede blevet brugt af os til at løse andre problemer:

  1. For hver klient skal vi vide, hvornår han så modstandere, da han trykkede på tasterne. Vi har allerede skrevet disse oplysninger ind i inputpakken og brugt dem til at justere klientforudsigelsen.
  2. Vi skal være i stand til at gemme historien om spiltilstande. Det er i den, vi vil holde vores modstanderes (og derfor deres kolliderers) positioner. Vi havde allerede en tilstandshistorik på serveren, vi brugte den til at bygge deltaer. Når vi kender det rigtige tidspunkt, kan vi nemt finde den rigtige tilstand i historien.
  3. Nu hvor vi har spillets tilstand fra historien i hånden, skal vi være i stand til at synkronisere spillerdata med tilstanden i den fysiske verden. Eksisterende kollidere - flyt, manglende - skab, unødvendige - ødelægge. Denne logik var også allerede skrevet og bestod af flere ECS-systemer. Vi brugte den til at holde flere spillerum i én Unity-proces. Og da den fysiske verden er én pr proces, skulle den genbruges mellem rum. Før hvert kryds i simuleringen "nulstillede" vi den fysiske verdens tilstand og geninitialiserede den med data for det aktuelle rum, og forsøgte at genbruge Unity-spilobjekter så meget som muligt gennem et smart pooling-system. Tilbage var blot at påkalde den samme logik for spiltilstanden fra fortiden.

Ved at sætte alle disse elementer sammen fik vi en "tidsmaskine", der kunne rulle den fysiske verdens tilstand tilbage til det rigtige øjeblik. Koden viste sig at 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;
     }
}

Det eneste, der var tilbage, var at finde ud af, hvordan man bruger denne maskine til nemt at kompensere for skud og evner.

I det enkleste tilfælde, når mekanikken er baseret på en enkelt hitscanning, synes alt at være klart: før spilleren skyder, skal han rulle den fysiske verden tilbage til den ønskede tilstand, lave en raycast, tælle hit eller miss, og bringe verden tilbage til den oprindelige tilstand.

Men der er meget få sådanne mekanikere i Dino Squad! De fleste af våbnene i spillet skaber projektiler - langlivede kugler, der flyver for adskillige simuleringsflåter (i nogle tilfælde snesevis af tæger). Hvad skal man gøre med dem, hvornår skal de flyve?

В gammel artikel om Half-Life-netværksstakken stillede gutterne fra Valve det samme spørgsmål, og deres svar var dette: projektilforsinkelseskompensation er problematisk, og det er bedre at undgå det.

Vi havde ikke denne mulighed: projektilbaserede våben var en nøglefunktion i spildesignet. Så vi måtte finde på noget. Efter lidt brainstorming formulerede vi to muligheder, der så ud til at virke:

1. Vi binder projektilet til tidspunktet for den spiller, der skabte det. Hvert kryds i serversimuleringen, for hver kugle af hver spiller, ruller vi den fysiske verden tilbage til klienttilstanden og udfører de nødvendige beregninger. Denne tilgang gjorde det muligt at have en fordelt belastning på serveren og forudsigelig flyvetid for projektiler. Forudsigelighed var især vigtig for os, da vi har alle projektiler, inklusive fjendtlige projektiler, forudsagt på klienten.

Hvordan vi lavede mekanikken til ballistisk beregning til et mobilt skydespil med en netværksforsinkelseskompensationsalgoritme
På billedet affyrer spilleren ved kryds 30 et missil i forventning: han ser i hvilken retning fjenden løber og kender missilets omtrentlige hastighed. Lokalt ser han, at han ramte målet ved det 33. kryds. Takket være forsinkelseskompensation vil den også vises på serveren

2. Vi gør alt det samme som i den første mulighed, men efter at have talt et flueben i kuglesimuleringen, stopper vi ikke, men fortsætter med at simulere dens flyvning inden for samme servertick, hver gang bringer dens tid tættere på serveren et efter et flueben og opdatering af kolliderende positioner. Det gør vi indtil en af ​​to ting sker:

  • Kuglen er udløbet. Det betyder, at beregningerne er slut, vi kan tælle en miss eller et hit. Og dette er på samme flueben, hvor skuddet blev affyret! For os var dette både et plus og et minus. Et plus - for for den skydende spiller dette reducerede forsinkelsen mellem slaget og faldet i fjendens helbred betydeligt. Ulempen er, at den samme effekt blev observeret, når modstandere skød mod spilleren: fjenden, ser det ud til, kun affyrede en langsom raket, og skaden var allerede talt.
  • Kuglen har nået servertid. I dette tilfælde vil dens simulering fortsætte i den næste servermarkering uden forsinkelseskompensation. For langsomme projektiler kan dette teoretisk reducere antallet af fysiktilbageføringer sammenlignet med den første mulighed. Samtidig steg den ujævne belastning af simuleringen: Serveren var enten inaktiv, eller i et server-tick beregnede den et dusin simulations-ticks for flere kugler.

Hvordan vi lavede mekanikken til ballistisk beregning til et mobilt skydespil med en netværksforsinkelseskompensationsalgoritme
Det samme scenarie som i det foregående billede, men beregnet efter den anden ordning. Missilet "indhentede" servertiden ved samme kryds, som skuddet fandt sted, og slaget kan tælles så tidligt som det næste kryds. Ved det 31. kryds, i dette tilfælde, anvendes forsinkelseskompensation ikke længere

I vores implementering adskilte disse to tilgange sig på blot et par linjer kode, så vi skabte begge, og i lang tid eksisterede de parallelt. Afhængigt af våbnets mekanik og kuglens hastighed valgte vi en eller anden mulighed for hver dinosaur. Vendepunktet her var udseendet i spillet af mekanik som "hvis du rammer fjenden så mange gange i sådan og sådan en tid, få sådan og sådan en bonus." Enhver mekaniker, hvor det tidspunkt, hvor spilleren ramte fjenden, spillede en vigtig rolle, nægtede at arbejde med den anden tilgang. Så vi endte med at gå med den første mulighed, og den gælder nu for alle våben og alle aktive evner i spillet.

Separat er det værd at rejse spørgsmålet om ydeevne. Hvis du troede, at alt dette ville bremse tingene, svarer jeg: det er det. Unity er ret langsom til at flytte kollidere og tænde og slukke dem. I Dino Squad kan der i det "værste" tilfælde være flere hundrede projektiler, der eksisterer samtidigt i kamp. At flytte kollidere for at tælle hvert projektil individuelt er en uoverkommelig luksus. Derfor var det absolut nødvendigt for os at minimere antallet af fysik-"rollbacks". For at gøre dette oprettede vi en separat komponent i ECS, hvor vi registrerer afspillerens tid. Vi føjede det til alle enheder, der kræver forsinkelseskompensation (projektiler, evner osv.). Før vi begynder at behandle sådanne entiteter, grupperer vi dem på dette tidspunkt og behandler dem sammen, og ruller den fysiske verden tilbage én gang for hver klynge.

På nuværende tidspunkt har vi et generelt fungerende system. Dens kode i en noget 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 der var tilbage var at konfigurere detaljerne:

1. Forstå, hvor meget man skal begrænse den maksimale bevægelsesafstand i tid.

Det var vigtigt for os at gøre spillet så tilgængeligt som muligt under forhold med dårlige mobilnetværk, så vi begrænsede historien med en margin på 30 ticks (med en tick rate på 20 Hz). Dette giver spillere mulighed for at ramme modstandere selv ved meget høje ping.

2. Bestem, hvilke objekter der kan flyttes i tid, og hvilke der ikke kan.

Vi flytter selvfølgelig vores modstandere. Men installerbare energiskjolde er det for eksempel ikke. Vi besluttede, at det var bedre at prioritere den defensive evne, som man ofte gør i online skydespil. Hvis spilleren allerede har placeret et skjold i nutiden, bør forsinkelseskompenserede kugler fra fortiden ikke flyve igennem det.

3. Tag stilling til, om det er nødvendigt at kompensere for dinosaurernes evner: bid, haleslag osv. Vi besluttede, hvad der skulle til, og behandler dem efter samme regler som kugler.

4. Bestem, hvad du skal gøre med kollidererne for den spiller, for hvem der udføres forsinkelseskompensation. På en god måde bør deres position ikke skifte til fortiden: spilleren skal se sig selv i samme tid, som han nu er på serveren. Vi ruller dog også skydespillerens kollidere tilbage, og det er der flere grunde til.

For det første forbedrer det clustering: vi kan bruge den samme fysiske tilstand for alle spillere med tætte ping.

For det andet, i alle raycasts og overlapninger udelukker vi altid kollidererne af den spiller, der ejer evnerne eller projektilerne. I Dino Squad kontrollerer spillerne dinosaurer, som har en temmelig ikke-standard geometri efter skydestandarder. Selvom spilleren skyder i en usædvanlig vinkel, og kuglens bane passerer gennem spillerens dinosaurkollider, vil kuglen ignorere den.

For det tredje beregner vi positionerne for dinosaurens våben eller anvendelsespunktet for evnen ved hjælp af data fra ECS, selv før start af forsinkelseskompensation.

Som et resultat er den reelle position for kollidererne af den lag-kompenserede spiller uvigtig for os, så vi tog en mere produktiv og samtidig enklere vej.

Netværksforsinkelse kan ikke blot fjernes, den kan kun maskeres. Ligesom enhver anden metode til forklædning har serverforsinkelseskompensation sine kompromiser. Det forbedrer spilleoplevelsen for den spiller, der skyder, på bekostning af den spiller, der bliver skudt på. For Dino Squad var valget dog oplagt her.

Alt dette skulle naturligvis også betales af serverkodens øgede kompleksitet som helhed – både for programmører og spildesignere. Hvis simuleringen tidligere var et simpelt sekventielt kald af systemer, så med forsinkelseskompensation dukkede indlejrede sløjfer og grene op i den. Vi brugte også mange kræfter på at gøre det praktisk at arbejde med.

I 2019-versionen (og måske lidt tidligere) tilføjede Unity fuld understøttelse af uafhængige fysiske scener. Vi implementerede dem på serveren næsten umiddelbart efter opdateringen, fordi vi hurtigt ville af med den fysiske verden, der er fælles for alle rum.

Vi gav hvert spillerum sin egen fysiske scene og eliminerede dermed behovet for at "rydde" scenen fra dataene i naborummet før beregning af simuleringen. For det første gav det en markant stigning i produktiviteten. For det andet gjorde det det muligt at slippe af med en hel klasse af fejl, der opstod, hvis programmøren lavede en fejl i sceneoprydningskoden, da han tilføjede nye spilelementer. Sådanne fejl var svære at fejlfinde, og de resulterede ofte i, at tilstanden af ​​fysiske objekter i et rums scene "flyder" ind i et andet rum.

Derudover forskede vi lidt i, om fysiske scener kunne bruges til at gemme den fysiske verdens historie. Det vil sige, betinget, tildel ikke én scene til hvert rum, men 30 scener, og lav en cyklisk buffer ud af dem, hvori historien kan lagres. Generelt viste muligheden sig at fungere, men vi implementerede den ikke: den viste ikke nogen vanvittig stigning i produktiviteten, men krævede ret risikable ændringer. Det var svært at forudsige, hvordan serveren ville opføre sig, når man arbejdede i lang tid med så mange scener. Derfor fulgte vi reglen: "Hvis det ikke er brudt, skal du ikke rette det'.

Kilde: www.habr.com

Tilføj en kommentar