Wie wir die Mechanik der ballistischen Berechnung für einen mobilen Schützen mit einem Netzwerkverzögerungskompensationsalgorithmus erstellt haben

Wie wir die Mechanik der ballistischen Berechnung für einen mobilen Schützen mit einem Netzwerkverzögerungskompensationsalgorithmus erstellt haben

Hallo, ich bin Nikita Brizhak, ein Serverentwickler von Pixonic. Heute möchte ich über den Ausgleich von Verzögerungen im mobilen Mehrspielermodus sprechen.

Es wurden viele Artikel über die Kompensation von Serververzögerungen geschrieben, auch auf Russisch. Dies ist nicht überraschend, da diese Technologie seit Ende der 90er Jahre aktiv bei der Entwicklung von Multiplayer-FPS eingesetzt wird. Sie können sich zum Beispiel an den QuakeWorld-Mod erinnern, der einer der ersten war, der ihn verwendete.

Wir verwenden es auch in unserem mobilen Multiplayer-Shooter Dino Squad.

Mein Ziel in diesem Artikel ist es nicht, das zu wiederholen, was bereits tausendmal geschrieben wurde, sondern zu erzählen, wie wir die Verzögerungskompensation in unserem Spiel implementiert haben und dabei unseren Technologie-Stack und die wichtigsten Gameplay-Funktionen berücksichtigt haben.

Ein paar Worte zu unserem Kortex und unserer Technologie.

Dino Squad ist ein mobiler PvP-Shooter im Netzwerk. Die Spieler steuern Dinosaurier, die mit verschiedenen Waffen ausgestattet sind, und kämpfen in 6-gegen-6-Teams gegeneinander.

Sowohl der Client als auch der Server basieren auf Unity. Die Architektur ist für Shooter recht klassisch: Der Server ist autoritär und die Client-Vorhersage funktioniert auf den Clients. Die Spielsimulation ist mit hauseigenem ECS geschrieben und kommt sowohl auf dem Server als auch auf dem Client zum Einsatz.

Wenn Sie zum ersten Mal von Lag-Kompensation hören, finden Sie hier einen kurzen Einblick in das Thema.

Bei Multiplayer-FPS-Spielen wird das Spiel normalerweise auf einem Remote-Server simuliert. Die Spieler senden ihre Eingaben (Informationen über die gedrückten Tasten) an den Server, und als Antwort sendet ihnen der Server einen aktualisierten Spielstatus unter Berücksichtigung der empfangenen Daten. Bei diesem Interaktionsschema ist die Verzögerung zwischen dem Drücken der Vorwärtstaste und dem Moment, in dem sich der Spielercharakter auf dem Bildschirm bewegt, immer größer als der Ping.

Während diese Verzögerung (im Volksmund Input-Lag genannt) in lokalen Netzwerken möglicherweise nicht wahrnehmbar ist, entsteht beim Spielen über das Internet das Gefühl, bei der Steuerung eines Charakters „auf Eis zu gleiten“. Dieses Problem ist für Mobilfunknetze doppelt relevant, wo der Ping eines Spielers bei 200 ms immer noch als hervorragende Verbindung gilt. Oft kann der Ping 350, 500 oder 1000 ms betragen. Dann wird es fast unmöglich, einen schnellen Shooter mit Input-Lag zu spielen.

Die Lösung für dieses Problem ist die clientseitige Simulationsvorhersage. Hier wendet der Client selbst die Eingabe auf den Spielercharakter an, ohne auf eine Antwort vom Server zu warten. Und wenn die Antwort eingeht, vergleicht es einfach die Ergebnisse und aktualisiert die Positionen der Gegner. Die Verzögerung zwischen dem Tastendruck und der Anzeige des Ergebnisses auf dem Bildschirm ist in diesem Fall minimal.

Es ist wichtig, die Nuance hier zu verstehen: Der Client zieht sich immer nach seiner letzten Eingabe und Feinde – mit Netzwerkverzögerung, nach dem vorherigen Status aus den Daten vom Server. Das heißt, wenn der Spieler auf einen Feind schießt, sieht er ihn relativ zu sich selbst in der Vergangenheit. Mehr über Kundenprognosen wir haben früher geschrieben.

Somit löst die Client-Vorhersage ein Problem, schafft aber ein anderes: Wenn ein Spieler auf den Punkt schießt, an dem sich der Feind in der Vergangenheit befand, befindet sich der Feind beim Schießen auf denselben Punkt auf dem Server möglicherweise nicht mehr an dieser Stelle. Die Serververzögerungskompensation versucht, dieses Problem zu lösen. Beim Abfeuern einer Waffe stellt der Server den Spielstand wieder her, den der Spieler zum Zeitpunkt des Schusses lokal gesehen hat, und prüft, ob er den Gegner wirklich hätte treffen können. Lautet die Antwort „Ja“, wird der Treffer gezählt, auch wenn sich der Gegner zu diesem Zeitpunkt nicht mehr auf dem Server befindet.

Mit diesem Wissen begannen wir mit der Implementierung der Serververzögerungskompensation in Dino Squad. Zunächst mussten wir verstehen, wie wir auf dem Server wiederherstellen können, was der Client gesehen hat. Und was genau muss wiederhergestellt werden? In unserem Spiel werden Treffer von Waffen und Fähigkeiten durch Raycasts und Overlays berechnet – also durch Interaktionen mit den physischen Collidern des Feindes. Dementsprechend mussten wir die Position dieser Collider, die der Spieler lokal „sah“, auf dem Server reproduzieren. Zu diesem Zeitpunkt verwendeten wir die Unity-Version 2018.x. Die dortige Physik-API ist statisch, die physische Welt existiert in einer einzigen Kopie. Es gibt keine Möglichkeit, den Status zu speichern und ihn dann aus der Box wiederherzustellen. Was also tun?

Die Lösung lag an der Oberfläche; alle ihre Elemente wurden von uns bereits zur Lösung anderer Probleme genutzt:

  1. Für jeden Kunden müssen wir wissen, zu welchem ​​Zeitpunkt er Gegner gesehen hat, als er die Tasten gedrückt hat. Wir haben diese Informationen bereits in das Eingabepaket geschrieben und zur Anpassung der Client-Vorhersage verwendet.
  2. Wir müssen in der Lage sein, den Verlauf der Spielzustände zu speichern. Darin werden wir die Positionen unserer Gegner (und damit ihrer Collider) halten. Wir hatten bereits einen Statusverlauf auf dem Server, den wir zum Erstellen verwendet haben Deltas. Wenn wir den richtigen Zeitpunkt kennen, können wir leicht den richtigen Zustand in der Geschichte finden.
  3. Da wir nun den Spielstand aus der Geschichte zur Hand haben, müssen wir in der Lage sein, Spielerdaten mit dem Zustand der physischen Welt zu synchronisieren. Vorhandene Collider – verschieben, fehlende – erstellen, unnötige – zerstören. Auch diese Logik war bereits geschrieben und bestand aus mehreren ECS-Systemen. Wir haben es verwendet, um mehrere Spielräume in einem Unity-Prozess zu verwalten. Und da es pro Prozess nur eine physische Welt gibt, musste sie zwischen den Räumen wiederverwendet werden. Vor jedem Tick der Simulation „setzen“ wir den Zustand der physischen Welt zurück und initialisieren sie mit Daten für den aktuellen Raum neu, wobei wir versuchen, Unity-Spielobjekte durch ein cleveres Pooling-System so weit wie möglich wiederzuverwenden. Es blieb nur noch, die gleiche Logik für den Spielstand aus der Vergangenheit heranzuziehen.

Durch die Kombination all dieser Elemente erhielten wir eine „Zeitmaschine“, die den Zustand der physischen Welt auf den richtigen Zeitpunkt zurücksetzen konnte. Der Code erwies sich als einfach:

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

Es blieb nur noch herauszufinden, wie man mit dieser Maschine Schüsse und Fähigkeiten einfach kompensieren kann.

Im einfachsten Fall, wenn die Mechanik auf einem einzigen Trefferscan basiert, scheint alles klar zu sein: Bevor der Spieler schießt, muss er die physische Welt in den gewünschten Zustand zurückversetzen, einen Raycast durchführen, die Treffer oder Fehlschüsse zählen und Bringen Sie die Welt in den Ausgangszustand zurück.

Aber es gibt nur sehr wenige solcher Mechaniken in Dino Squad! Die meisten Waffen im Spiel erzeugen Projektile – langlebige Kugeln, die mehrere Simulationsticks fliegen (in manchen Fällen Dutzende von Ticks). Was tun mit ihnen, wann sollen sie fliegen?

В alter Artikel Zum Half-Life-Netzwerk-Stack stellten die Jungs von Valve die gleiche Frage und ihre Antwort lautete: Die Kompensation der Projektilverzögerung ist problematisch und es ist besser, sie zu vermeiden.

Diese Option hatten wir nicht: Projektilbasierte Waffen waren ein Schlüsselmerkmal des Spieldesigns. Also mussten wir uns etwas einfallen lassen. Nach einigem Brainstorming haben wir zwei Optionen formuliert, die zu funktionieren schienen:

1. Wir verknüpfen das Projektil mit der Zeit des Spielers, der es erstellt hat. Bei jedem Tick der Serversimulation, bei jeder Kugel jedes Spielers setzen wir die physische Welt auf den Client-Zustand zurück und führen die notwendigen Berechnungen durch. Dieser Ansatz ermöglichte eine verteilte Last auf dem Server und eine vorhersehbare Flugzeit von Projektilen. Vorhersehbarkeit war für uns besonders wichtig, da wir alle Projektile, auch feindliche Projektile, auf dem Client vorhersagen können.

Wie wir die Mechanik der ballistischen Berechnung für einen mobilen Schützen mit einem Netzwerkverzögerungskompensationsalgorithmus erstellt haben
Im Bild feuert der Spieler bei Tick 30 erwartungsvoll eine Rakete ab: Er sieht, in welche Richtung der Feind rennt und kennt die ungefähre Geschwindigkeit der Rakete. Vor Ort sieht er, dass er das Ziel beim 33. Tick getroffen hat. Dank Lag-Kompensation erscheint es auch auf dem Server

2. Wir machen alles wie bei der ersten Option, aber nachdem wir einen Tick der Bullet-Simulation gezählt haben, hören wir nicht auf, sondern simulieren weiterhin seinen Flug innerhalb desselben Server-Ticks, wobei wir seine Zeit jedes Mal näher an den Server bringen einen nach dem anderen ticken und die Collider-Positionen aktualisieren. Wir tun dies, bis eines von zwei Dingen passiert:

  • Die Kugel ist abgelaufen. Dies bedeutet, dass die Berechnungen abgeschlossen sind und wir einen Fehlschuss oder einen Treffer zählen können. Und das im selben Tick, in dem der Schuss abgefeuert wurde! Für uns war das sowohl ein Plus als auch ein Minus. Ein Pluspunkt – denn für den schießenden Spieler wurde dadurch die Verzögerung zwischen dem Treffer und der Verschlechterung der Gesundheit des Gegners erheblich verkürzt. Der Nachteil ist, dass der gleiche Effekt beobachtet wurde, wenn Gegner auf den Spieler feuerten: Der Feind feuerte scheinbar nur eine langsame Rakete ab und der Schaden wurde bereits gezählt.
  • Die Kugel hat die Serverzeit erreicht. In diesem Fall wird die Simulation im nächsten Server-Tick ohne Verzögerungskompensation fortgesetzt. Bei langsamen Projektilen könnte dies theoretisch die Anzahl der Physik-Rollbacks im Vergleich zur ersten Option reduzieren. Gleichzeitig nahm die ungleichmäßige Belastung der Simulation zu: Der Server war entweder im Leerlauf oder er berechnete in einem Server-Tick ein Dutzend Simulations-Ticks für mehrere Aufzählungspunkte.

Wie wir die Mechanik der ballistischen Berechnung für einen mobilen Schützen mit einem Netzwerkverzögerungskompensationsalgorithmus erstellt haben
Das gleiche Szenario wie im vorherigen Bild, jedoch berechnet nach dem zweiten Schema. Die Rakete „holte“ die Serverzeit im selben Tick ein, in dem der Schuss erfolgte, und der Treffer kann bereits beim nächsten Tick gezählt werden. Beim 31. Tick wird in diesem Fall die Verzögerungskompensation nicht mehr angewendet

In unserer Implementierung unterschieden sich diese beiden Ansätze nur in wenigen Codezeilen, also haben wir beide erstellt, und sie existierten lange Zeit parallel. Abhängig von der Mechanik der Waffe und der Geschwindigkeit der Kugel haben wir für jeden Dinosaurier die eine oder andere Option gewählt. Der Wendepunkt hier war das Auftauchen von Mechaniken im Spiel wie „Wenn du den Feind so oft in dieser oder jener Zeit triffst, bekommst du diesen oder jenen Bonus.“ Jeder Mechaniker, bei dem der Zeitpunkt, zu dem der Spieler den Feind trifft, eine wichtige Rolle spielte, weigerte sich, mit dem zweiten Ansatz zu arbeiten. Also haben wir uns letztendlich für die erste Option entschieden, und sie gilt nun für alle Waffen und alle aktiven Fähigkeiten im Spiel.

Unabhängig davon lohnt es sich, die Frage der Leistung anzusprechen. Wenn Sie dachten, dass all dies die Dinge verlangsamen würde, antworte ich: Das ist es. Unity ist ziemlich langsam beim Bewegen und Ein- und Ausschalten von Kollidern. In Dino Squad können im „Worst Case“-Szenario mehrere hundert Projektile gleichzeitig im Kampf vorhanden sein. Der Einsatz von Collidern, um jedes Projektil einzeln zu zählen, ist ein unerschwinglicher Luxus. Daher war es für uns unbedingt erforderlich, die Anzahl der physikalischen „Rollbacks“ zu minimieren. Dazu haben wir in ECS eine eigene Komponente erstellt, in der wir die Zeit des Spielers erfassen. Wir haben es allen Entitäten hinzugefügt, die eine Verzögerungskompensation erfordern (Projektile, Fähigkeiten usw.). Bevor wir mit der Verarbeitung solcher Entitäten beginnen, gruppieren wir sie zu diesem Zeitpunkt und verarbeiten sie gemeinsam, wobei wir die physische Welt für jeden Cluster einmal zurücksetzen.

Zu diesem Zeitpunkt verfügen wir über ein allgemein funktionierendes System. Sein Code in etwas vereinfachter 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);
               }
          }
     }
}

Es blieben nur noch die Details zu konfigurieren:

1. Verstehen Sie, wie stark die maximale Bewegungsdistanz zeitlich begrenzt werden kann.

Es war uns wichtig, das Spiel unter Bedingungen schlechter Mobilfunknetze so zugänglich wie möglich zu machen, deshalb haben wir die Geschichte auf einen Spielraum von 30 Ticks (mit einer Tick-Rate von 20 Hz) begrenzt. Dies ermöglicht es Spielern, Gegner auch bei sehr hohen Pings zu treffen.

2. Bestimmen Sie, welche Objekte zeitlich verschoben werden können und welche nicht.

Natürlich bewegen wir unsere Gegner. Installierbare Energieschilde zum Beispiel sind es aber nicht. Wir haben entschieden, dass es besser ist, der Verteidigungsfähigkeit Vorrang einzuräumen, wie es bei Online-Shootern oft der Fall ist. Wenn der Spieler in der Gegenwart bereits einen Schild platziert hat, sollten verzögerungskompensierte Kugeln aus der Vergangenheit nicht durch diesen hindurchfliegen.

3. Entscheiden Sie, ob es notwendig ist, die Fähigkeiten der Dinosaurier zu kompensieren: Biss, Schwanzschlag usw. Wir entscheiden, was nötig ist, und verarbeiten sie nach den gleichen Regeln wie Kugeln.

4. Bestimmen Sie, was mit den Collidern des Spielers geschehen soll, für den eine Verzögerungskompensation durchgeführt wird. Im positiven Sinne sollte sich ihre Position nicht in die Vergangenheit verschieben: Der Spieler sollte sich in der gleichen Zeit sehen, in der er sich jetzt auf dem Server befindet. Allerdings setzen wir auch die Collider des schießenden Spielers zurück, und dafür gibt es mehrere Gründe.

Erstens verbessert es das Clustering: Wir können für alle Spieler mit nahe beieinander liegenden Pings denselben physischen Zustand verwenden.

Zweitens schließen wir bei allen Raycasts und Überlappungen immer die Collider des Spielers aus, der die Fähigkeiten oder Projektile besitzt. In Dino Squad steuern die Spieler Dinosaurier, deren Geometrie für Shooter-Verhältnisse eher ungewöhnlich ist. Selbst wenn der Spieler aus einem ungewöhnlichen Winkel schießt und die Flugbahn des Geschosses durch den Dinosaurier-Collider des Spielers verläuft, ignoriert das Geschoss dies.

Drittens berechnen wir die Positionen der Waffe des Dinosauriers oder den Einsatzpunkt der Fähigkeit anhand von Daten aus dem ECS bereits vor Beginn der Verzögerungskompensation.

Dadurch ist die tatsächliche Position der Collider des Lag-kompensierten Players für uns unwichtig, sodass wir einen produktiveren und gleichzeitig einfacheren Weg eingeschlagen haben.

Netzwerklatenz kann nicht einfach entfernt, sondern nur maskiert werden. Wie jede andere Verschleierungsmethode hat auch die Kompensation von Serververzögerungen Nachteile. Es verbessert das Spielerlebnis des Spielers, der schießt, auf Kosten des Spielers, auf den geschossen wird. Für Dino Squad lag die Wahl hier jedoch auf der Hand.

All dies musste natürlich auch durch die erhöhte Komplexität des gesamten Servercodes erkauft werden – sowohl für Programmierer als auch für Spieleentwickler. War die Simulation früher ein einfacher sequentieller Aufruf von Systemen, so erschienen darin mit Verzögerungskompensation verschachtelte Schleifen und Verzweigungen. Wir haben uns auch große Mühe gegeben, die Arbeit damit bequem zu gestalten.

In der Version 2019 (und vielleicht etwas früher) hat Unity die volle Unterstützung für unabhängige physische Szenen hinzugefügt. Wir haben sie fast unmittelbar nach dem Update auf dem Server implementiert, weil wir die allen Räumen gemeinsame physische Welt schnell loswerden wollten.

Wir haben jedem Spielraum eine eigene physische Szene gegeben und so die Notwendigkeit beseitigt, die Szene vor der Berechnung der Simulation aus den Daten des Nachbarraums zu „löschen“. Erstens führte es zu einer deutlichen Produktivitätssteigerung. Zweitens war es möglich, eine ganze Reihe von Fehlern zu beseitigen, die auftraten, wenn dem Programmierer beim Hinzufügen neuer Spielelemente ein Fehler im Szenenbereinigungscode unterlief. Solche Fehler waren schwer zu beheben und führten häufig dazu, dass der Zustand physischer Objekte in der Szene eines Raums in einen anderen Raum „überging“.

Darüber hinaus haben wir untersucht, ob physische Szenen zur Speicherung der Geschichte der physischen Welt genutzt werden können. Das heißt, bedingt, jedem Raum nicht eine Szene, sondern 30 Szenen zuzuweisen und daraus einen zyklischen Puffer zu erstellen, in dem die Geschichte gespeichert wird. Im Großen und Ganzen erwies sich die Option als funktionierend, aber wir haben sie nicht umgesetzt: Sie zeigte keine verrückte Steigerung der Produktivität, erforderte aber eher riskante Änderungen. Es war schwer vorherzusagen, wie sich der Server verhalten würde, wenn man über einen längeren Zeitraum mit so vielen Szenen arbeitete. Deshalb haben wir uns an die Regel gehalten: „Wenn es nicht kaputt ist, reparieren Sie es nicht".

Source: habr.com

Kommentar hinzufügen