Hur vi förbättrade mekaniken för ballistiska beräkningar för ett mobilt skjutspel med en algoritm för kompensation för nätverkslatens

Hur vi förbättrade mekaniken för ballistiska beräkningar för ett mobilt skjutspel med en algoritm för kompensation för nätverkslatens

Hej, jag heter Nikita Brizhak, en serverutvecklare från Pixonic. Idag skulle jag vilja prata om att kompensera för fördröjning i mobil multiplayer.

Många artiklar har skrivits om serverfördröjningskompensation, inklusive på ryska. Detta är inte förvånande, eftersom denna teknik har använts aktivt i skapandet av multiplayer FPS sedan slutet av 90-talet. Till exempel kan du komma ihåg QuakeWorld-moden, som var en av de första som använde den.

Vi använder det också i vår mobila flerspelarshooter Dino Squad.

I den här artikeln är mitt mål inte att upprepa det som redan har skrivits tusen gånger, utan att berätta hur vi implementerade fördröjningskompensation i vårt spel, med hänsyn till vår teknikstack och kärnspelsfunktioner.

Några ord om vår cortex och teknik.

Dino Squad är ett nätverksmobilt PvP-skjutspel. Spelare kontrollerar dinosaurier utrustade med en mängd olika vapen och slåss mot varandra i 6-6-lag.

Både klienten och servern är baserade på Unity. Arkitekturen är ganska klassisk för shooters: servern är auktoritär och klientförutsägelse fungerar på klienterna. Spelsimuleringen är skriven med in-house ECS och används på både server och klient.

Om det är första gången du hör talas om eftersläpningskompensation kommer här en kort utflykt till frågan.

I multiplayer FPS-spel simuleras matchen vanligtvis på en fjärrserver. Spelare skickar sin inmatning (information om de knappar som trycks ner) till servern, och som svar skickar servern dem ett uppdaterat spelläge med hänsyn till mottagna data. Med detta interaktionsschema kommer fördröjningen mellan att trycka på framåtknappen och det ögonblick då spelarkaraktären rör sig på skärmen alltid vara större än pingen.

På lokala nätverk kan denna fördröjning (populärt kallad ingångsfördröjning) vara omärkbar, när man spelar via Internet skapar den en känsla av att "glida på isen" när man styr en karaktär. Detta problem är dubbelt relevant för mobilnätverk, där fallet när en spelares ping är 200 ms fortfarande anses vara en utmärkt anslutning. Ofta kan pinget vara 350, 500 eller 1000 ms. Då blir det nästan omöjligt att spela en snabb shooter med input lag.

Lösningen på detta problem är simuleringsförutsägelse på klientsidan. Här applicerar klienten själv input till spelarkaraktären, utan att vänta på svar från servern. Och när svaret tas emot jämför den helt enkelt resultaten och uppdaterar motståndarnas positioner. Fördröjningen mellan att trycka på en tangent och visa resultatet på skärmen i detta fall är minimal.

Det är viktigt att förstå nyansen här: klienten ritar alltid sig själv enligt sin senaste inmatning, och fiender - med nätverksfördröjning, enligt det tidigare tillståndet från data från servern. Det vill säga, när han skjuter mot en fiende, ser spelaren honom i det förflutna i förhållande till sig själv. Mer om klientförutsägelse vi skrev tidigare.

Klientförutsägelse löser alltså ett problem, men skapar ett annat: om en spelare skjuter på den punkt där fienden var tidigare, på servern när han skjuter på samma punkt, kanske fienden inte längre är på den platsen. Serverfördröjningskompensation försöker lösa detta problem. När ett vapen avfyras, återställer servern speltillståndet som spelaren såg lokalt vid tidpunkten för skottet, och kontrollerar om han verkligen kunde ha träffat fienden. Om svaret är "ja" räknas träffen, även om fienden inte längre är på servern vid den tidpunkten.

Beväpnade med denna kunskap började vi implementera serverfördröjningskompensation i Dino Squad. Först och främst var vi tvungna att förstå hur man återställer på servern vad klienten såg? Och exakt vad behöver återställas? I vårt spel beräknas träffar från vapen och förmågor genom strålkastningar och överlagringar - det vill säga genom interaktioner med fiendens fysiska kolliderare. Följaktligen behövde vi reproducera positionen för dessa kolliderare, som spelaren "såg" lokalt, på servern. Vid den tiden använde vi Unity version 2018.x. Fysikens API där är statisk, den fysiska världen finns i en enda kopia. Det finns inget sätt att spara dess tillstånd och sedan återställa det från lådan. Så vad ska man göra?

Lösningen fanns på ytan; alla dess element hade redan använts av oss för att lösa andra problem:

  1. För varje klient måste vi veta vid vilken tidpunkt han såg motståndare när han tryckte på tangenterna. Vi har redan skrivit in denna information i indatapaketet och använt den för att justera klientförutsägelsen.
  2. Vi måste kunna lagra historien om speltillstånd. Det är i den som vi kommer att hålla positionerna för våra motståndare (och därför deras kolliderande). Vi hade redan en statushistorik på servern, vi använde den för att bygga delta. Genom att veta rätt tidpunkt kan vi lätt hitta rätt tillstånd i historien.
  3. Nu när vi har speltillståndet från historien i handen måste vi kunna synkronisera spelardata med tillståndet i den fysiska världen. Befintliga kolliderar - flytta, saknade - skapa, onödiga - förstöra. Denna logik var också redan skriven och bestod av flera ECS-system. Vi använde den för att hålla flera spelrum i en Unity-process. Och eftersom den fysiska världen är en per process, var den tvungen att återanvändas mellan rummen. Före varje tick av simuleringen "återställer" vi den fysiska världens tillstånd och återinitierade den med data för det aktuella rummet, och försökte återanvända Unity-spelobjekt så mycket som möjligt genom ett smart poolningssystem. Allt som återstod var att åberopa samma logik för speltillståndet från det förflutna.

Genom att sätta ihop alla dessa element fick vi en "tidsmaskin" som kunde rulla tillbaka den fysiska världens tillstånd till rätt ögonblick. Koden visade sig vara 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;
     }
}

Allt som återstod var att ta reda på hur man använder den här maskinen för att enkelt kompensera för skott och förmågor.

I det enklaste fallet, när mekaniken är baserad på en enda träffskanning, verkar allt vara klart: innan spelaren skjuter måste han rulla tillbaka den fysiska världen till önskat tillstånd, göra en raycast, räkna träffen eller missen, och återställa världen till det ursprungliga tillståndet.

Men det finns väldigt få sådana mekaniker i Dino Squad! De flesta av vapnen i spelet skapar projektiler - kulor med lång livslängd som flyger för flera simuleringsfästningar (i vissa fall dussintals fästingar). Vad ska man göra med dem, vilken tid ska de flyga?

В gammal artikel om Half-Life-nätverksstacken ställde killarna från Valve samma fråga, och deras svar var detta: projektilfördröjningskompensation är problematisk, och det är bättre att undvika det.

Vi hade inte det här alternativet: projektilbaserade vapen var en nyckelfunktion i speldesignen. Så vi var tvungna att hitta på något. Efter lite brainstorming formulerade vi två alternativ som verkade fungera:

1. Vi knyter projektilen till tiden för spelaren som skapade den. Varje tick av serversimuleringen, för varje kula av varje spelare, rullar vi tillbaka den fysiska världen till klienttillståndet och utför de nödvändiga beräkningarna. Detta tillvägagångssätt gjorde det möjligt att ha en fördelad belastning på servern och förutsägbar flygtid för projektiler. Förutsägbarhet var särskilt viktigt för oss, eftersom vi har alla projektiler, inklusive fiendens projektiler, förutspådda på klienten.

Hur vi förbättrade mekaniken för ballistiska beräkningar för ett mobilt skjutspel med en algoritm för kompensation för nätverkslatens
På bilden avfyrar spelaren vid bock 30 en missil i väntan: han ser åt vilket håll fienden springer och vet missilens ungefärliga hastighet. Lokalt ser han att han träffade målet vid den 33:e bocken. Tack vare fördröjningskompensation kommer den även att dyka upp på servern

2. Vi gör allt på samma sätt som i det första alternativet, men efter att ha räknat en tick av kulsimuleringen, stannar vi inte utan fortsätter att simulera dess flygning inom samma server tick, och för varje gång dess tid närmare servern en efter en bock och uppdatering av kolliderarpositioner. Vi gör detta tills en av två saker händer:

  • Kulan har gått ut. Det betyder att beräkningarna är över, vi kan räkna en miss eller en träff. Och detta är vid samma bock som skottet avlossades i! För oss var detta både ett plus och ett minus. Ett plus - för den skjutande spelaren minskade detta avsevärt fördröjningen mellan träffen och minskningen av fiendens hälsa. Nackdelen är att samma effekt observerades när motståndarna sköt mot spelaren: fienden, verkar det som, bara avfyrade en långsam raket, och skadan var redan räknad.
  • Kulan har nått servertid. I det här fallet kommer dess simulering att fortsätta i nästa servertick utan någon fördröjningskompensation. För långsamma projektiler skulle detta teoretiskt kunna minska antalet fysikåterställningar jämfört med det första alternativet. Samtidigt ökade den ojämna belastningen på simuleringen: servern var antingen inaktiv eller i en servertick beräknade den ett dussin simuleringstick för flera kulor.

Hur vi förbättrade mekaniken för ballistiska beräkningar för ett mobilt skjutspel med en algoritm för kompensation för nätverkslatens
Samma scenario som i föregående bild, men beräknat enligt det andra schemat. Missilen "kom ikapp" servertiden vid samma tick som skottet inträffade, och träffen kan räknas så tidigt som nästa tick. Vid den 31:a bocken, i detta fall, tillämpas inte längre fördröjningskompensation

I vår implementering skilde sig dessa två tillvägagångssätt på bara ett par rader kod, så vi skapade båda, och under lång tid existerade de parallellt. Beroende på vapnets mekanik och kulans hastighet valde vi ett eller annat alternativ för varje dinosaurie. Vändpunkten här var utseendet i spelet av mekanik som "om du träffar fienden så många gånger under en sådan tid, få en sådan och en bonus." Alla mekaniker där tidpunkten då spelaren träffade fienden spelade en viktig roll vägrade att arbeta med det andra tillvägagångssättet. Så det slutade med att vi valde det första alternativet, och det gäller nu för alla vapen och alla aktiva förmågor i spelet.

Separat är det värt att ta upp frågan om prestanda. Om du trodde att allt detta skulle sakta ner, svarar jag: det är det. Unity är ganska långsam när det gäller att flytta kolliderare och slå på och av dem. I Dino Squad, i "värsta" fall, kan det finnas flera hundra projektiler som existerar samtidigt i strid. Att flytta kolliderar för att räkna varje projektil individuellt är en oöverkomlig lyx. Därför var det absolut nödvändigt för oss att minimera antalet fysik-”återställningar”. För att göra detta skapade vi en separat komponent i ECS där vi registrerar spelarens tid. Vi lade till det till alla enheter som kräver fördröjningskompensation (projektiler, förmågor, etc.). Innan vi börjar bearbeta sådana entiteter, grupperar vi dem vid det här laget och bearbetar dem tillsammans, rullar tillbaka den fysiska världen en gång för varje kluster.

I det här skedet har vi ett allmänt fungerande system. Dess kod i en något förenklad 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);
               }
          }
     }
}

Allt som återstod var att konfigurera detaljerna:

1. Förstå hur mycket man ska begränsa det maximala rörelseavståndet i tiden.

Det var viktigt för oss att göra spelet så tillgängligt som möjligt under förhållanden med dåliga mobilnät, så vi begränsade historien med en marginal på 30 tick (med en tickhastighet på 20 Hz). Detta tillåter spelare att slå motståndare även vid mycket höga pingar.

2. Bestäm vilka objekt som kan flyttas i tid och vilka som inte kan.

Vi flyttar naturligtvis våra motståndare. Men det är till exempel inte installerbara energisköldar. Vi bestämde oss för att det var bättre att prioritera den defensiva förmågan, som man ofta gör i online-skyttar. Om spelaren redan har placerat en sköld i nuet, bör fördröjningskompenserade kulor från det förflutna inte flyga genom den.

3. Bestäm om det är nödvändigt att kompensera för dinosauriernas förmågor: bett, svansslag etc. Vi bestämde vad som behövdes och behandlar dem enligt samma regler som kulor.

4. Bestäm vad du ska göra med kolliderarna för spelaren för vilken fördröjningskompensation utförs. På ett bra sätt bör deras position inte flyttas till det förflutna: spelaren ska se sig själv i samma tid som han nu är på servern. Men vi rullar också tillbaka kolliderarna för den skytte, och det finns flera anledningar till detta.

För det första förbättrar det klustring: vi kan använda samma fysiska tillstånd för alla spelare med nära pingar.

För det andra, i alla strålkastningar och överlappningar utesluter vi alltid kolliderarna från spelaren som äger förmågorna eller projektilerna. I Dino Squad kontrollerar spelarna dinosaurier, som har en ganska onormal geometri enligt shooter-standarder. Även om spelaren skjuter i en ovanlig vinkel och kulans bana passerar genom spelarens dinosauriekollider, kommer kulan att ignorera den.

För det tredje beräknar vi positionerna för dinosauriens vapen eller tillämpningspunkten för förmågan med hjälp av data från ECS redan innan eftersläpningskompensationen börjar.

Som ett resultat är den verkliga positionen för kolliderarna för den fördröjningskompenserade spelaren oviktig för oss, så vi tog en mer produktiv och samtidigt enklare väg.

Nätverkslatens kan inte bara tas bort, den kan bara maskeras. Liksom alla andra metoder för förklädnad har kompensation för serverfördröjning sina kompromisser. Det förbättrar spelupplevelsen för spelaren som skjuter på bekostnad av spelaren som skjuts mot. För Dino Squad var dock valet här självklart.

Allt detta fick naturligtvis också betalas av den ökade komplexiteten hos serverkoden som helhet – både för programmerare och speldesigners. Om tidigare simuleringen var ett enkelt sekventiellt anrop av system, med fördröjningskompensation dök kapslade loopar och grenar upp i den. Vi har också lagt ner mycket kraft på att göra det bekvämt att arbeta med.

I 2019 års version (och kanske lite tidigare) lade Unity till fullt stöd för oberoende fysiska scener. Vi implementerade dem på servern nästan direkt efter uppdateringen, eftersom vi snabbt ville bli av med den fysiska värld som är gemensam för alla rum.

Vi gav varje spelrum sin egen fysiska scen och eliminerade därmed behovet av att "rensa" scenen från data från grannrummet innan simuleringen beräknades. För det första gav det en betydande ökning av produktiviteten. För det andra gjorde det det möjligt att bli av med en hel klass av buggar som uppstod om programmeraren gjorde ett fel i scenrensningskoden när han lade till nya spelelement. Sådana fel var svåra att felsöka och de resulterade ofta i att fysiska objekts tillstånd i ett rums scen "flödade" in i ett annat rum.

Dessutom undersökte vi om fysiska scener kunde användas för att lagra den fysiska världens historia. Det vill säga, villkorligt, tilldela inte en scen till varje rum, utan 30 scener, och gör en cyklisk buffert av dem, där du kan lagra berättelsen. I allmänhet visade sig alternativet fungera, men vi implementerade det inte: det visade ingen galen ökning av produktiviteten, utan krävde ganska riskabla förändringar. Det var svårt att förutse hur servern skulle bete sig när man arbetade länge med så många scener. Därför följde vi regeln: "Om det inte är bristet, fixa inte det".

Källa: will.com

Lägg en kommentar