Comment nous avons amélioré la mécanique des calculs balistiques pour un jeu de tir mobile avec un algorithme de compensation de latence réseau

Comment nous avons amélioré la mécanique des calculs balistiques pour un jeu de tir mobile avec un algorithme de compensation de latence réseau

Bonjour, je m'appelle Nikita Brizhak, développeur de serveurs chez Pixonic. Aujourd'hui, je voudrais parler de la compensation du décalage dans le multijoueur mobile.

De nombreux articles ont été écrits sur la compensation du décalage du serveur, notamment en russe. Ce n'est pas surprenant, puisque cette technologie est activement utilisée dans la création de FPS multijoueurs depuis la fin des années 90. Par exemple, vous vous souvenez du mod QuakeWorld, qui fut l'un des premiers à l'utiliser.

Nous l'utilisons également dans notre jeu de tir multijoueur mobile Dino Squad.

Dans cet article, mon objectif n'est pas de répéter mille fois ce qui a déjà été écrit, mais de raconter comment nous avons implémenté la compensation du décalage dans notre jeu, en tenant compte de notre pile technologique et des fonctionnalités de base du gameplay.

Quelques mots sur notre cortex et notre technologie.

Dino Squad est un jeu de tir PvP mobile en réseau. Les joueurs contrôlent des dinosaures équipés de diverses armes et s'affrontent en équipes 6v6.

Le client et le serveur sont basés sur Unity. L'architecture est assez classique pour les shooters : le serveur est autoritaire, et la prédiction client fonctionne sur les clients. La simulation de jeu est écrite à l’aide d’ECS interne et est utilisée à la fois sur le serveur et sur le client.

Si c'est la première fois que vous entendez parler de compensation du décalage, voici une brève excursion sur le sujet.

Dans les jeux FPS multijoueurs, le match est généralement simulé sur un serveur distant. Les joueurs envoient leurs entrées (informations sur les touches enfoncées) au serveur et, en réponse, le serveur leur envoie un état de jeu mis à jour en tenant compte des données reçues. Avec ce schéma d'interaction, le délai entre l'appui sur la touche avant et le moment où le personnage du joueur se déplace sur l'écran sera toujours supérieur au ping.

Alors que sur les réseaux locaux, ce délai (communément appelé input lag) peut être imperceptible, lors de la lecture via Internet, il crée une sensation de « glisser sur la glace » lors du contrôle d'un personnage. Ce problème est doublement pertinent pour les réseaux mobiles, où le cas où le ping d'un joueur est de 200 ms est toujours considéré comme une excellente connexion. Le ping peut souvent être de 350, 500 ou 1000 XNUMX ms. Il devient alors presque impossible de jouer à un jeu de tir rapide avec un décalage d'entrée.

La solution à ce problème est la prédiction par simulation côté client. Ici, le client applique lui-même l'entrée au personnage du joueur, sans attendre une réponse du serveur. Et lorsque la réponse est reçue, il compare simplement les résultats et met à jour les positions des adversaires. Le délai entre l'appui sur une touche et l'affichage du résultat à l'écran est dans ce cas minime.

Il est important de comprendre ici la nuance : le client se dessine toujours en fonction de sa dernière entrée, et les ennemis - avec un retard du réseau, en fonction de l'état précédent à partir des données du serveur. Autrement dit, lorsqu'il tire sur un ennemi, le joueur le voit dans le passé par rapport à lui-même. En savoir plus sur la prédiction client nous avons écrit plus tôt.

Ainsi, la prédiction client résout un problème, mais en crée un autre : si un joueur tire à l'endroit où se trouvait l'ennemi dans le passé, sur le serveur lorsqu'il tire au même endroit, l'ennemi peut ne plus être à cet endroit. La compensation du décalage du serveur tente de résoudre ce problème. Lorsqu'une arme est tirée, le serveur rétablit l'état du jeu que le joueur a vu localement au moment du tir, et vérifie s'il aurait réellement pu toucher l'ennemi. Si la réponse est « oui », le coup est compté, même si l'ennemi n'est plus sur le serveur à ce moment-là.

Forts de ces connaissances, nous avons commencé à implémenter une compensation du décalage du serveur dans Dino Squad. Tout d'abord, il a fallu comprendre comment restaurer sur le serveur ce que le client a vu ? Et que faut-il restaurer exactement ? Dans notre jeu, les coups des armes et des capacités sont calculés via des raycasts et des superpositions, c'est-à-dire via des interactions avec les collisionneurs physiques de l'ennemi. Il nous fallait donc reproduire la position de ces collisionneurs, que le joueur « voyait » localement, sur le serveur. À cette époque, nous utilisions Unity version 2018.x. L'API physique y est statique, le monde physique existe en un seul exemplaire. Il n’existe aucun moyen de sauvegarder son état puis de le restaurer depuis la boîte. Alors que faire?

La solution était superficielle ; tous ses éléments avaient déjà été utilisés par nous pour résoudre d’autres problèmes :

  1. Pour chaque client, il faut savoir à quelle heure il a vu des adversaires lorsqu'il a appuyé sur les touches. Nous avons déjà écrit ces informations dans le package d'entrée et les avons utilisées pour ajuster la prédiction du client.
  2. Nous devons pouvoir stocker l’historique des états du jeu. C'est là que nous occuperons les positions de nos adversaires (et donc de leurs collisionneurs). Nous avions déjà un historique d'état sur le serveur, nous l'avons utilisé pour construire delta. Connaissant le bon moment, nous pourrions facilement trouver le bon état dans l’histoire.
  3. Maintenant que nous avons en main l’état du jeu issu de l’histoire, nous devons être capables de synchroniser les données des joueurs avec l’état du monde physique. Collisionneurs existants - déplacer, ceux manquants - créer, ceux inutiles - détruire. Cette logique était également déjà écrite et composée de plusieurs systèmes ECS. Nous l'avons utilisé pour organiser plusieurs salles de jeux dans un seul processus Unity. Et comme le monde physique est un par processus, il a dû être réutilisé entre les pièces. Avant chaque tick de simulation, nous "réinitialisons" l'état du monde physique et le réinitialisons avec les données de la salle actuelle, en essayant de réutiliser autant que possible les objets du jeu Unity grâce à un système de pooling intelligent. Il ne restait plus qu’à invoquer la même logique pour l’état du jeu du passé.

En réunissant tous ces éléments, nous avons obtenu une « machine à remonter le temps » capable de ramener l’état du monde physique au bon moment. Le code s'est avéré simple :

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

Il ne restait plus qu'à trouver comment utiliser cette machine pour compenser facilement les tirs et les capacités.

Dans le cas le plus simple, lorsque la mécanique est basée sur un seul hitscan, tout semble clair : avant de tirer, le joueur doit ramener le monde physique à l'état souhaité, faire un raycast, compter les coups ou les ratés, et ramener le monde à son état initial.

Mais il existe très peu de telles mécaniques dans Dino Squad ! La plupart des armes du jeu créent des projectiles - des balles à longue durée de vie qui volent pendant plusieurs ticks de simulation (dans certains cas, des dizaines de ticks). Que faire d'eux, à quelle heure doivent-ils voler ?

В article ancien à propos de la pile réseau Half-Life, les gars de Valve ont posé la même question, et leur réponse a été la suivante : la compensation du décalage des projectiles est problématique, et il vaut mieux l'éviter.

Nous n'avions pas cette option : les armes basées sur des projectiles étaient un élément clé de la conception du jeu. Il fallait donc trouver quelque chose. Après quelques réflexions, nous avons formulé deux options qui semblaient fonctionner :

1. Nous lions le projectile à l'époque du joueur qui l'a créé. À chaque tick de la simulation du serveur, pour chaque balle de chaque joueur, nous ramenons le monde physique à l'état client et effectuons les calculs nécessaires. Cette approche a permis d'avoir une charge répartie sur le serveur et un temps de vol prévisible des projectiles. La prévisibilité était particulièrement importante pour nous, puisque nous disposions de tous les projectiles, y compris les projectiles ennemis, prédits sur le client.

Comment nous avons amélioré la mécanique des calculs balistiques pour un jeu de tir mobile avec un algorithme de compensation de latence réseau
Sur l'image, le joueur au tick 30 tire un missile par anticipation : il voit dans quelle direction court l'ennemi et connaît la vitesse approximative du missile. Localement, il voit qu'il a touché la cible au 33ème tick. Grâce à la compensation du décalage, il apparaîtra également sur le serveur

2. Nous faisons tout de la même manière que dans la première option, mais, après avoir compté un tick de simulation de balle, nous ne nous arrêtons pas, mais continuons à simuler son vol au sein du même tick de serveur, en rapprochant à chaque fois son temps du serveur un par un, et mise à jour des positions des collisionneurs. Nous faisons cela jusqu'à ce que l'une des deux choses suivantes se produise :

  • La balle est expirée. Cela veut dire que les calculs sont terminés, on peut compter un raté ou un coup sûr. Et c'est au même moment que le coup de feu a été tiré ! Pour nous, c'était à la fois un plus et un moins. Un plus - car pour le tireur, cela réduisait considérablement le délai entre le coup et la diminution de la santé de l'ennemi. L'inconvénient est que le même effet a été observé lorsque les adversaires ont tiré sur le joueur : l'ennemi, semble-t-il, n'a tiré qu'une fusée lente, et les dégâts étaient déjà comptés.
  • La balle a atteint l'heure du serveur. Dans ce cas, sa simulation se poursuivra au prochain tick du serveur sans aucune compensation de décalage. Pour les projectiles lents, cela pourrait théoriquement réduire le nombre de retours en arrière physiques par rapport à la première option. Dans le même temps, la charge inégale sur la simulation a augmenté : soit le serveur était inactif, soit en un seul tick de serveur, il calculait une douzaine de ticks de simulation pour plusieurs balles.

Comment nous avons amélioré la mécanique des calculs balistiques pour un jeu de tir mobile avec un algorithme de compensation de latence réseau
Le même scénario que dans l'image précédente, mais calculé selon le deuxième schéma. Le missile a « rattrapé » l'heure du serveur au même tick que le tir a eu lieu, et le coup peut être compté dès le tick suivant. Au 31ème tick, dans ce cas, la compensation du décalage n'est plus appliquée

Dans notre implémentation, ces deux approches ne différaient que par quelques lignes de code, nous avons donc créé les deux, et elles ont longtemps existé en parallèle. En fonction de la mécanique de l'arme et de la vitesse de la balle, nous avons choisi l'une ou l'autre option pour chaque dinosaure. Le tournant ici a été l'apparition dans le jeu d'une mécanique du type "si vous frappez l'ennemi autant de fois à tel ou tel moment, obtenez tel ou tel bonus". Toute mécanique où le moment où le joueur frappait l'ennemi jouait un rôle important refusait de travailler avec la deuxième approche. Nous avons donc opté pour la première option, et elle s’applique désormais à toutes les armes et à toutes les capacités actives du jeu.

Séparément, il convient de soulever la question de la performance. Si vous pensiez que tout cela allait ralentir les choses, je réponds : c'est le cas. Unity est assez lent à déplacer les collisionneurs et à les allumer et à les éteindre. Dans Dino Squad, dans le « pire » des cas, plusieurs centaines de projectiles peuvent exister simultanément au combat. Déplacer les collisionneurs pour compter chaque projectile individuellement est un luxe inabordable. Il était donc absolument nécessaire pour nous de minimiser le nombre de « rollbacks » physiques. Pour ce faire, nous avons créé un composant distinct dans ECS dans lequel nous enregistrons le temps du joueur. Nous l'avons ajouté à toutes les entités qui nécessitent une compensation de décalage (projectiles, capacités, etc.). Avant de commencer à traiter de telles entités, nous les regroupons à ce moment-là et les traitons ensemble, en restaurant le monde physique une fois pour chaque cluster.

À ce stade, nous disposons d’un système globalement fonctionnel. Son code sous une forme quelque peu simplifiée :

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

Il ne restait plus qu'à configurer les détails :

1. Comprenez dans quelle mesure limiter la distance maximale de mouvement dans le temps.

Il était important pour nous de rendre le jeu aussi accessible que possible dans des conditions de réseaux mobiles médiocres, nous avons donc limité l'histoire avec une marge de 30 ticks (avec un taux de tick de 20 Hz). Cela permet aux joueurs de frapper leurs adversaires même avec des pings très élevés.

2. Déterminez quels objets peuvent être déplacés dans le temps et lesquels ne le peuvent pas.

Bien entendu, nous déplaçons nos adversaires. Mais les boucliers énergétiques installables, par exemple, ne le sont pas. Nous avons décidé qu'il valait mieux donner la priorité à la capacité défensive, comme cela se fait souvent dans les jeux de tir en ligne. Si le joueur a déjà placé un bouclier dans le présent, les balles compensées du passé ne devraient pas le traverser.

3. Décidez s'il est nécessaire de compenser les capacités des dinosaures : morsure, coup de queue, etc. Nous avons décidé de ce qui était nécessaire et les traitons selon les mêmes règles que les balles.

4. Déterminez quoi faire avec les collisionneurs du joueur pour lequel une compensation du décalage est effectuée. Dans le bon sens, leur position ne doit pas basculer dans le passé : le joueur doit se voir au même moment où il se trouve actuellement sur le serveur. Cependant, nous faisons également reculer les collisionneurs du joueur qui tire, et il y a plusieurs raisons à cela.

Premièrement, cela améliore le clustering : nous pouvons utiliser le même état physique pour tous les joueurs avec des pings proches.

Deuxièmement, dans tous les raycasts et chevauchements, nous excluons toujours les collisionneurs du joueur qui possède les capacités ou les projectiles. Dans Dino Squad, les joueurs contrôlent des dinosaures, qui ont une géométrie plutôt non standard par rapport aux standards des jeux de tir. Même si le joueur tire sous un angle inhabituel et que la trajectoire de la balle traverse le collisionneur de dinosaures du joueur, la balle l'ignorera.

Troisièmement, nous calculons les positions de l'arme du dinosaure ou le point d'application de la capacité en utilisant les données de l'ECS avant même le début de la compensation du décalage.

En conséquence, la position réelle des collisionneurs du joueur compensé en décalage n'a pas d'importance pour nous, nous avons donc choisi une voie plus productive et en même temps plus simple.

La latence du réseau ne peut pas simplement être supprimée, elle peut seulement être masquée. Comme toute autre méthode de déguisement, la compensation du décalage du serveur a ses inconvénients. Cela améliore l’expérience de jeu du joueur qui tire au détriment du joueur visé. Pour Dino Squad, cependant, le choix était ici évident.

Bien entendu, tout cela a également dû être payé par la complexité accrue du code du serveur dans son ensemble, tant pour les programmeurs que pour les concepteurs de jeux. Si auparavant la simulation était un simple appel séquentiel de systèmes, alors avec la compensation du décalage, des boucles et des branches imbriquées y apparaissaient. Nous avons également déployé beaucoup d’efforts pour rendre le travail plus pratique.

Dans la version 2019 (et peut-être un peu plus tôt), Unity a ajouté la prise en charge complète des scènes physiques indépendantes. Nous les avons implémentés sur le serveur presque immédiatement après la mise à jour, car nous souhaitions nous débarrasser rapidement du monde physique commun à toutes les salles.

Nous avons donné à chaque salle de jeu sa propre scène physique et avons ainsi éliminé le besoin de « nettoyer » la scène des données de la salle voisine avant de calculer la simulation. Premièrement, cela a entraîné une augmentation significative de la productivité. Deuxièmement, cela a permis de se débarrasser de toute une classe de bugs qui survenaient si le programmeur faisait une erreur dans le code de nettoyage de la scène lors de l'ajout de nouveaux éléments de jeu. De telles erreurs étaient difficiles à déboguer et entraînaient souvent le « flux » de l'état des objets physiques dans la scène d'une pièce dans une autre pièce.

De plus, nous avons effectué des recherches pour déterminer si les scènes physiques pouvaient être utilisées pour stocker l’histoire du monde physique. Autrement dit, sous condition, n'attribuez pas une scène à chaque pièce, mais 30 scènes, et créez-en un tampon cyclique dans lequel stocker l'histoire. En général, l'option s'est avérée efficace, mais nous ne l'avons pas mise en œuvre : elle n'a montré aucune augmentation folle de la productivité, mais a nécessité des changements plutôt risqués. Il était difficile de prédire comment le serveur se comporterait en travaillant longtemps avec autant de scènes. Nous avons donc suivi la règle : «Si ce n'est pas cassé, ne le répare pas».

Source: habr.com

Ajouter un commentaire