Come abbiamo migliorato la meccanica dei calcoli balistici per un tiratore mobile con un algoritmo di compensazione della latenza di rete

Come abbiamo migliorato la meccanica dei calcoli balistici per un tiratore mobile con un algoritmo di compensazione della latenza di rete

Ciao, sono Nikita Brizhak, uno sviluppatore di server di Pixonic. Oggi vorrei parlare della compensazione del ritardo nel multiplayer mobile.

Sono stati scritti molti articoli sulla compensazione del ritardo del server, anche in russo. Ciò non sorprende, dal momento che questa tecnologia è stata utilizzata attivamente nella creazione di FPS multiplayer dalla fine degli anni '90. Ad esempio, puoi ricordare la mod QuakeWorld, che è stata una delle prime ad usarla.

Lo usiamo anche nel nostro sparatutto multiplayer mobile Dino Squad.

In questo articolo, il mio obiettivo non è ripetere ciò che è già stato scritto mille volte, ma raccontare come abbiamo implementato la compensazione del lag nel nostro gioco, tenendo conto del nostro stack tecnologico e delle caratteristiche principali del gameplay.

Qualche parola sulla nostra corteccia e sulla tecnologia.

Dino Squad è uno sparatutto PvP mobile in rete. I giocatori controllano dinosauri equipaggiati con una varietà di armi e si combattono tra loro in squadre 6v6.

Sia il client che il server sono basati su Unity. L'architettura è piuttosto classica per gli sparatutto: il server è autoritario e la previsione del client funziona sui client. La simulazione del gioco è scritta utilizzando ECS interno e viene utilizzata sia sul server che sul client.

Se è la prima volta che senti parlare di compensazione del ritardo, ecco una breve introduzione alla questione.

Nei giochi FPS multiplayer, la partita viene solitamente simulata su un server remoto. I giocatori inviano i loro input (informazioni sui tasti premuti) al server e in risposta il server invia loro uno stato di gioco aggiornato tenendo conto dei dati ricevuti. Con questo schema di interazione, il ritardo tra la pressione del tasto avanti e il momento in cui il personaggio del giocatore si muove sullo schermo sarà sempre maggiore del ping.

Mentre sulle reti locali questo ritardo (popolarmente chiamato input lag) può essere impercettibile, quando si gioca via Internet crea la sensazione di "scivolare sul ghiaccio" quando si controlla un personaggio. Questo problema è doppiamente rilevante per le reti mobili, dove il caso in cui il ping di un giocatore è di 200 ms è ancora considerato un'ottima connessione. Spesso il ping può essere di 350, 500 o 1000 ms. Quindi diventa quasi impossibile giocare a uno sparatutto veloce con input lag.

La soluzione a questo problema è la previsione della simulazione lato client. Qui è il client stesso ad applicare l'input al personaggio del giocatore, senza attendere una risposta dal server. E quando riceve la risposta, confronta semplicemente i risultati e aggiorna le posizioni degli avversari. In questo caso il ritardo tra la pressione di un tasto e la visualizzazione del risultato sullo schermo è minimo.

È importante comprendere la sfumatura qui: il client si disegna sempre in base al suo ultimo input e i nemici - con ritardo di rete, in base allo stato precedente dai dati del server. Cioè, quando spara a un nemico, il giocatore lo vede nel passato rispetto a se stesso. Ulteriori informazioni sulla previsione del cliente abbiamo scritto prima.

Pertanto, la previsione del client risolve un problema, ma ne crea un altro: se un giocatore spara nel punto in cui si trovava il nemico in passato, sul server quando spara nello stesso punto, il nemico potrebbe non trovarsi più in quel posto. La compensazione del ritardo del server tenta di risolvere questo problema. Quando si spara con un'arma, il server ripristina lo stato del gioco che il giocatore vedeva localmente al momento dello sparo, e controlla se davvero avrebbe potuto colpire il nemico. Se la risposta è "sì", il colpo viene conteggiato, anche se in quel momento il nemico non si trova più sul server.

Forti di questa conoscenza, abbiamo iniziato a implementare la compensazione del ritardo del server in Dino Squad. Innanzitutto dovevamo capire come ripristinare sul server ciò che vedeva il client? E cosa deve essere ripristinato esattamente? Nel nostro gioco, i colpi delle armi e delle abilità vengono calcolati tramite raycast e sovrapposizioni, ovvero attraverso le interazioni con i collisori fisici del nemico. Di conseguenza, dovevamo riprodurre la posizione di questi collisori, che il giocatore “vedeva” localmente, sul server. A quel tempo utilizzavamo la versione Unity 2018.x. L'API fisica è statica, il mondo fisico esiste in un'unica copia. Non c'è modo di salvarne lo stato e poi ripristinarlo dalla scatola. Quindi che si fa?

La soluzione era in superficie; tutti i suoi elementi erano già stati da noi utilizzati per risolvere altri problemi:

  1. Per ogni cliente dobbiamo sapere a che ora ha visto gli avversari quando ha premuto i tasti. Abbiamo già scritto queste informazioni nel pacchetto di input e le abbiamo utilizzate per regolare la previsione del client.
  2. Dobbiamo essere in grado di memorizzare la cronologia degli stati del gioco. È in esso che manterremo le posizioni dei nostri avversari (e quindi dei loro collisori). Avevamo già una cronologia dello stato sul server, l'abbiamo utilizzata per creare delta. Conoscendo il momento giusto, potremmo facilmente trovare lo stato giusto nella storia.
  3. Ora che abbiamo in mano lo stato del gioco dalla storia, dobbiamo essere in grado di sincronizzare i dati dei giocatori con lo stato del mondo fisico. I collisori esistenti - si spostano, quelli mancanti - creano, quelli non necessari - distruggono. Anche questa logica era già scritta e consisteva in diversi sistemi ECS. Lo abbiamo utilizzato per ospitare diverse sale giochi in un unico processo Unity. E poiché il mondo fisico è uno per processo, doveva essere riutilizzato tra le stanze. Prima di ogni tick della simulazione, abbiamo "resettato" lo stato del mondo fisico e lo abbiamo reinizializzato con i dati della stanza corrente, cercando di riutilizzare il più possibile gli oggetti di gioco di Unity attraverso un intelligente sistema di pooling. Non restava che invocare la stessa logica del passato per lo stato del gioco.

Mettendo insieme tutti questi elementi, abbiamo ottenuto una “macchina del tempo” in grado di riportare lo stato del mondo fisico al momento giusto. Il codice si è rivelato semplice:

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

Non restava che capire come utilizzare questa macchina per compensare facilmente colpi e abilità.

Nel caso più semplice, quando la meccanica si basa su un singolo hitscan, tutto sembra essere chiaro: prima che il giocatore spari, deve riportare il mondo fisico allo stato desiderato, eseguire un raycast, contare i colpi mancati e riportare il mondo allo stato iniziale.

Ma ci sono pochissimi meccanismi simili in Dino Squad! La maggior parte delle armi nel gioco creano proiettili: proiettili di lunga durata che volano per diversi tick di simulazione (in alcuni casi, dozzine di tick). Cosa fare con loro, a che ora dovrebbero volare?

В articolo antico riguardo allo stack di rete di Half-Life, i ragazzi di Valve hanno posto la stessa domanda e la loro risposta è stata questa: la compensazione del ritardo del proiettile è problematica ed è meglio evitarla.

Non avevamo questa opzione: le armi a proiettile erano una caratteristica fondamentale del design del gioco. Quindi dovevamo inventare qualcosa. Dopo un po’ di brainstorming, abbiamo formulato due opzioni che sembravano funzionare:

1. Leghiamo il proiettile all'ora del giocatore che lo ha creato. Per ogni tick della simulazione del server, per ogni proiettile di ogni giocatore, riportiamo il mondo fisico allo stato client ed eseguiamo i calcoli necessari. Questo approccio ha permesso di avere un carico distribuito sul server e un tempo di volo prevedibile dei proiettili. La prevedibilità è stata particolarmente importante per noi, poiché abbiamo previsto tutti i proiettili, compresi quelli nemici, sul client.

Come abbiamo migliorato la meccanica dei calcoli balistici per un tiratore mobile con un algoritmo di compensazione della latenza di rete
Nell'immagine, il giocatore al punto 30 lancia un missile in anticipo: vede in quale direzione sta correndo il nemico e conosce la velocità approssimativa del missile. Localmente vede che ha centrato il bersaglio al 33esimo tick. Grazie alla compensazione del ritardo, apparirà anche sul server

2. Facciamo tutto come nella prima opzione, ma, dopo aver contato un tick della simulazione del proiettile, non ci fermiamo, ma continuiamo a simulare il suo volo all'interno dello stesso tick del server, avvicinando ogni volta il suo tempo al server uno per uno spunta e aggiorna le posizioni del collisore. Lo facciamo finché non accade una delle due cose:

  • Il proiettile è scaduto. Ciò significa che i calcoli sono finiti, possiamo contare un errore o un successo. E questo avviene nello stesso momento in cui è stato sparato il colpo! Per noi questo è stato sia un vantaggio che uno svantaggio. Un vantaggio: perché per il giocatore che spara ciò riduce significativamente il ritardo tra il colpo e la diminuzione della salute del nemico. Lo svantaggio è che lo stesso effetto è stato osservato quando gli avversari hanno sparato al giocatore: il nemico, a quanto pare, ha lanciato solo un razzo lento e il danno era già contato.
  • Il proiettile ha raggiunto l'ora del server. In questo caso, la simulazione continuerà nel prossimo tick del server senza alcuna compensazione del ritardo. Per i proiettili lenti, ciò potrebbe teoricamente ridurre il numero di rollback fisici rispetto alla prima opzione. Allo stesso tempo, il carico irregolare sulla simulazione è aumentato: il server era inattivo o in un tick del server calcolava una dozzina di tick di simulazione per diversi proiettili.

Come abbiamo migliorato la meccanica dei calcoli balistici per un tiratore mobile con un algoritmo di compensazione della latenza di rete
Lo stesso scenario dell'immagine precedente, ma calcolato secondo il secondo schema. Il missile ha "raggiunto" l'ora del server nello stesso tick in cui è avvenuto il tiro e il colpo può essere conteggiato già al tick successivo. Al 31° tick, in questo caso, la compensazione del ritardo non viene più applicata

Nella nostra implementazione, questi due approcci differivano solo per un paio di righe di codice, quindi li abbiamo creati entrambi e per molto tempo sono esistiti in parallelo. A seconda della meccanica dell'arma e della velocità del proiettile, abbiamo scelto l'una o l'altra opzione per ciascun dinosauro. Il punto di svolta qui è stata l'apparizione nel gioco di meccaniche come "se colpisci il nemico così tante volte in un determinato momento, ottieni questo e quell'altro bonus". Qualsiasi meccanica in cui il momento in cui il giocatore ha colpito il nemico giocava un ruolo importante si è rifiutata di lavorare con il secondo approccio. Alla fine abbiamo optato per la prima opzione, che ora si applica a tutte le armi e a tutte le abilità attive nel gioco.

Separatamente, vale la pena sollevare la questione delle prestazioni. Se pensavi che tutto questo avrebbe rallentato le cose, ti rispondo: è così. Unity è piuttosto lento nello spostare i collisori e nell'accenderli e spegnerli. In Dino Squad, nel caso "peggiore", possono esserci diverse centinaia di proiettili contemporaneamente in combattimento. Spostare i collisori per contare ogni proiettile individualmente è un lusso insostenibile. Pertanto, era assolutamente necessario per noi ridurre al minimo il numero di “rollback” fisici. Per fare ciò, abbiamo creato un componente separato in ECS in cui registriamo il tempo del giocatore. L'abbiamo aggiunto a tutte le entità che richiedono una compensazione del ritardo (proiettili, abilità, ecc.). Prima di iniziare a elaborare tali entità, a questo punto le raggruppiamo e le elaboriamo insieme, ripristinando il mondo fisico una volta per ciascun cluster.

In questa fase abbiamo un sistema generalmente funzionante. Il suo codice in una forma un po' semplificata:

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

Non restava che configurare i dettagli:

1. Comprendere quanto limitare la distanza massima di movimento nel tempo.

Per noi era importante rendere il gioco il più accessibile possibile in condizioni di reti mobili scarse, quindi abbiamo limitato la storia con un margine di 30 tick (con una frequenza di tick di 20 Hz). Ciò consente ai giocatori di colpire gli avversari anche con ping molto alti.

2. Determinare quali oggetti possono essere spostati nel tempo e quali no.

Naturalmente stiamo muovendo i nostri avversari. Ma gli scudi energetici installabili, ad esempio, non lo sono. Abbiamo deciso che fosse meglio dare priorità alla capacità difensiva, come spesso avviene negli sparatutto online. Se il giocatore ha già posizionato uno scudo nel presente, i proiettili del passato con compensazione del ritardo non dovrebbero attraversarlo.

3. Decidi se è necessario compensare le capacità dei dinosauri: morso, colpo di coda, ecc. Abbiamo deciso cosa era necessario e lo elaboriamo secondo le stesse regole dei proiettili.

4. Determinare cosa fare con i collider del lettore per il quale viene eseguita la compensazione del ritardo. In senso buono, la loro posizione non dovrebbe spostarsi nel passato: il giocatore dovrebbe vedere se stesso nello stesso momento in cui si trova ora sul server. Tuttavia, ripristiniamo anche i collisori del giocatore che spara, e ci sono diverse ragioni per questo.

Innanzitutto migliora il clustering: possiamo utilizzare lo stesso stato fisico per tutti i giocatori con ping vicini.

In secondo luogo, in tutti i raycast e le sovrapposizioni escludiamo sempre i collisori del giocatore che possiede le abilità o i proiettili. In Dino Squad, i giocatori controllano i dinosauri, che hanno una geometria piuttosto non standard per gli standard degli sparatutto. Anche se il giocatore spara con un'angolazione insolita e la traiettoria del proiettile passa attraverso il collisore dei dinosauri del giocatore, il proiettile lo ignorerà.

In terzo luogo, calcoliamo la posizione dell’arma del dinosauro o il punto di applicazione dell’abilità utilizzando i dati dell’ECS anche prima dell’inizio della compensazione del ritardo.

Di conseguenza, la posizione reale dei collisori del lettore con compensazione del ritardo non è importante per noi, quindi abbiamo intrapreso un percorso più produttivo e allo stesso tempo più semplice.

La latenza della rete non può essere semplicemente rimossa, può solo essere mascherata. Come ogni altro metodo di mascheramento, la compensazione del ritardo del server ha i suoi compromessi. Migliora l'esperienza di gioco del giocatore che spara a spese del giocatore colpito. Per Dino Squad, tuttavia, la scelta era ovvia.

Naturalmente, tutto ciò ha dovuto essere pagato anche dalla maggiore complessità del codice del server nel suo complesso, sia per i programmatori che per i progettisti di giochi. Se prima la simulazione era una semplice chiamata sequenziale di sistemi, allora con la compensazione del ritardo apparivano cicli e rami annidati. Abbiamo anche dedicato molti sforzi per renderlo comodo da usare.

Nella versione del 2019 (e forse un po' prima), Unity ha aggiunto il pieno supporto per scene fisiche indipendenti. Li abbiamo implementati sul server quasi immediatamente dopo l'aggiornamento, perché volevamo liberarci rapidamente del mondo fisico comune a tutte le stanze.

Abbiamo dato ad ogni sala giochi la propria scena fisica eliminando così la necessità di “cancellare” la scena dai dati della stanza vicina prima di calcolare la simulazione. In primo luogo, ha dato un aumento significativo della produttività. In secondo luogo, ha permesso di eliminare un'intera classe di bug che si verificavano se il programmatore commetteva un errore nel codice di pulizia della scena durante l'aggiunta di nuovi elementi di gioco. Tali errori erano difficili da correggere e spesso facevano sì che lo stato degli oggetti fisici nella scena di una stanza "scorresse" in un'altra stanza.

Inoltre, abbiamo svolto alcune ricerche per verificare se le scene fisiche potessero essere utilizzate per archiviare la storia del mondo fisico. Cioè, a condizione, non assegnare una scena a ciascuna stanza, ma 30 scene e creare da esse un buffer ciclico in cui archiviare la storia. In generale, l'opzione si è rivelata funzionante, ma non l'abbiamo implementata: non ha mostrato alcun aumento pazzesco della produttività, ma ha richiesto modifiche piuttosto rischiose. Era difficile prevedere come si sarebbe comportato il server lavorando a lungo con così tante scene. Pertanto, abbiamo seguito la regola: “Se non è rotto, non aggiustarlo'.

Fonte: habr.com

Aggiungi un commento