TL; DR
Ich habe eine Demo erstellt, die zeigt, wie die clientseitige Vorhersage der physischen Bewegung eines Spielers in Unity -
GitHub implementiert wird.
Einführung
Anfang 2012 schrieb ich einen
Beitrag über die Implementierung von Prognosen auf der Client-Seite der physischen Bewegung eines Spielers in Unity. Dank
Physics.Simulate () wird diese von mir beschriebene ungeschickte
Problemumgehung nicht mehr benötigt. Der alte Beitrag ist immer noch einer der beliebtesten in meinem Blog, aber für die moderne Einheit sind diese Informationen bereits falsch. Daher veröffentliche ich die Version 2018.
Was ist auf der Client-Seite?
In wettbewerbsfähigen Multiplayer-Spielen sollte Betrug nach Möglichkeit vermieden werden. Normalerweise bedeutet dies, dass ein Netzwerkmodell mit einem autoritären Server verwendet wird: Die Clients senden die eingegebenen Informationen an den Server, und der Server wandelt diese Informationen in die Bewegung eines Spielers um und sendet dann eine Momentaufnahme des Status des Spielers an den Client. In diesem Fall gibt es eine Verzögerung zwischen dem Drücken der Taste und der Anzeige des Ergebnisses, die für aktive Spiele nicht akzeptabel ist. Die Vorhersage auf der Client-Seite ist eine sehr beliebte Technik, die die Verzögerung verbirgt, die resultierende Bewegung vorhersagt und sie dem Spieler sofort anzeigt. Wenn der Client die Ergebnisse vom Server empfängt, vergleicht er sie mit den Vorhersagen des Clients. Wenn sie sich unterscheiden, war die Prognose fehlerhaft und muss korrigiert werden.
Vom Server empfangene Snapshots stammen immer aus der Vergangenheit in Bezug auf den vorhergesagten Status des Clients (wenn beispielsweise die Übertragung von Daten vom Client zum Server und zurück 150 ms dauert, wird jeder Snapshot um mindestens 150 ms verzögert). Wenn der Kunde die falsche Prognose korrigieren muss, muss er daher zu diesem Punkt in der Vergangenheit zurückkehren und dann alle in die Lücke eingegebenen Informationen reproduzieren, um zu seinem Standort zurückzukehren. Wenn die Bewegung des Spielers im Spiel auf Physik basiert, wird Physics.Simulate () benötigt, um mehrere Zyklen in einem Frame zu simulieren. Wenn beim Bewegen des Players nur Character Controller (oder Kapselguss usw.) verwendet werden, können Sie auf Physics.Simulate () verzichten - und ich gehe davon aus, dass die Leistung besser ist.
Ich werde Unity verwenden, um eine Netzwerkdemo namens
" Glenn Fiedlers Zen of Networked Physics" neu zu erstellen
, die mir seit langem Spaß macht. Der Spieler hat einen physischen Würfel, auf den er Kraft ausüben und ihn in die Szene schieben kann. Die Demo simuliert verschiedene Netzwerkbedingungen, einschließlich Verzögerung und Paketverlust.
An die Arbeit gehen
Als erstes müssen Sie die automatische Physiksimulation ausschalten. Obwohl wir mit Physics.Simulate () dem physischen System mitteilen können, wann die Simulation gestartet werden soll, führt es die Simulation standardmäßig automatisch basierend auf einem festen Projektzeitdelta durch. Daher deaktivieren wir es unter
Bearbeiten-> Projekteinstellungen-> Physik, indem wir das
Kontrollkästchen "Automatische Simulation " deaktivieren.
Zunächst erstellen wir eine einfache Einzelbenutzerimplementierung. Die Eingabe wird abgetastet (w, a, s, d zum Bewegen und Platz zum Springen), und alles hängt von den einfachen Kräften ab, die mit AddForce () auf den starren Körper ausgeübt werden.
public class Logic : MonoBehaviour { public GameObject player; private float timer; private void Start() { this.timer = 0.0f; } private void Update() { this.timer += Time.deltaTime; while (this.timer >= Time.fixedDeltaTime) { this.timer -= Time.fixedDeltaTime; Inputs inputs; inputs.up = Input.GetKey(KeyCode.W); inputs.down = Input.GetKey(KeyCode.S); inputs.left = Input.GetKey(KeyCode.A); inputs.right = Input.GetKey(KeyCode.D); inputs.jump = Input.GetKey(KeyCode.Space); this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs); Physics.Simulate(Time.fixedDeltaTime); } } }
Spieler bewegen sich, während das Netzwerk nicht verwendet wirdSenden von Eingaben an den Server
Jetzt müssen wir die Eingabe an den Server senden, der auch diesen Bewegungscode ausführt, einen Schnappschuss des Status des Cubes erstellen und ihn an den Client zurücksenden.
Bisher nichts Besonderes hier, das einzige, worauf ich achten möchte, ist das Hinzufügen der Variablen tick_number. Wenn der Server Snapshots des Status des Cubes an den Client zurücksendet, können wir herausfinden, welcher Takt des Clients diesem Status entspricht, damit wir diesen Status mit dem vorhergesagten Client vergleichen können (den wir etwas später hinzufügen werden).
Alles ist einfach - der Server wartet auf Eingabenachrichten, simuliert beim Empfang einen Taktzyklus. Dann macht er eine Momentaufnahme des resultierenden Zustands des Cubes und sendet ihn an den Client zurück. Möglicherweise stellen Sie fest, dass tick_number in der Statusnachricht um eins größer ist als tick_number in der Eingabenachricht. Dies geschieht, weil es für mich persönlich intuitiv bequemer ist, den "Zustand des Spielers in Takt 100" als den "Zustand des Spielers zu
Beginn von Takt 100" zu betrachten. Daher erzeugt der Status des Spielers in Takt 100 in Kombination mit der Eingabe des Spielers in Takt 100 einen neuen Status für den Spieler in Takt 101.
Zustand n + Eingang n = Zustand n + 1
Ich sage nicht, dass Sie es genauso nehmen sollten, die Hauptsache ist die Konstanz des Ansatzes.
Es muss auch gesagt werden, dass ich diese Nachrichten nicht über einen echten Socket
sende , sondern sie imitiere, indem ich sie in die Warteschlange schreibe und Paketverzögerung und -verlust simuliere. Die Szene enthält zwei physische Cubes - einen für den Client und einen für den Server. Beim Aktualisieren des Client-Cubes deaktiviere ich das GameObject des Server-Cubes und umgekehrt.
Ich simuliere jedoch nicht Network Bounce und Paketzustellung in der falschen Reihenfolge, weshalb ich davon ausgehe, dass jede empfangene Eingangsnachricht neuer als die vorherige ist. Diese Nachahmung wird benötigt, um ganz einfach "Client" und "Server" in einer Unity-Instanz auszuführen, sodass wir Server- und Client-Cubes in einer Szene kombinieren können.
Sie können auch feststellen, dass der Server weniger Taktzyklen als der Client simuliert und daher einen anderen Status erstellt, wenn die Eingabenachricht verworfen wird und den Server nicht erreicht. Dies ist wahr, aber selbst wenn wir diese Auslassungen simulieren würden, könnte die Eingabe immer noch falsch sein, was auch zu einem anderen Zustand führen würde. Wir werden uns später mit diesem Problem befassen.
Es sollte auch hinzugefügt werden, dass es in diesem Beispiel nur einen Client gibt, was die Arbeit vereinfacht. Wenn wir mehrere Clients hätten, müssten wir a) beim Aufrufen von Physics.Simulate () überprüfen, ob nur der Cube eines Spielers auf dem Server aktiviert ist, oder b) wenn der Server Eingaben von mehreren Cubes erhalten hat, alle zusammen simulieren.
Verzögerung 75 ms (150 ms Hin- und Rückfahrt)
0% verlorene Pakete
Gelber Würfel - Server-Spieler
Blauer Würfel - der letzte vom Client empfangene SchnappschussBisher sieht alles gut aus, aber ich war ein wenig wählerisch bei dem, was ich auf dem Video aufgenommen habe, um ein ziemlich ernstes Problem zu verbergen.
Bestimmungsfehler
Schauen Sie sich das jetzt an:
Autsch ...Dieses Video wurde aufgenommen, ohne Pakete zu verlieren. Die Simulationen variieren jedoch immer noch mit genau derselben Eingabe. Ich verstehe nicht ganz, warum dies passiert - PhysX sollte ziemlich deterministisch sein, daher finde ich es auffällig, dass die Simulationen so oft voneinander abweichen. Dies kann an der Tatsache liegen, dass ich GameObject-Cubes ständig aktiviere und deaktiviere. Das heißt, es ist möglich, dass das Problem bei Verwendung von zwei verschiedenen Unity-Instanzen abnimmt. Es kann ein Fehler sein, wenn Sie es im Code auf GitHub sehen, dann lassen Sie es mich wissen.
Wie dem auch sei, falsche Prognosen sind eine wesentliche Tatsache bei der Prognose auf Kundenseite. Lassen Sie uns also damit umgehen.
Kann ich zurückspulen?
Der Vorgang ist recht einfach: Wenn der Kunde eine Bewegung vorhersagt, speichert er einen Statuspuffer (Position und Drehung) und eine Eingabe. Nach dem Empfang einer Statusnachricht vom Server wird der empfangene Status mit dem vorhergesagten Status aus dem Puffer verglichen. Wenn sie sich um einen zu großen Wert unterscheiden, definieren wir den Status des Client-Cubes in der Vergangenheit neu und simulieren dann erneut alle Zwischenmaßnahmen.
Gepufferte Eingabe- und Statusdaten werden in einem sehr einfachen Umlaufpuffer gespeichert, in dem die Kennzahl als Index verwendet wird. Und ich habe den Wert von 64 Hz für die Taktfrequenz der Physik gewählt, dh ein Puffer von 1024 Elementen gibt uns Platz für 16 Sekunden, und dies ist viel mehr als das, was wir benötigen.
Korrektur ist an!Redundante Eingabeübertragung
Eingabenachrichten sind normalerweise sehr klein - die gedrückten Tasten können zu einem Bitfeld kombiniert werden, das nur wenige Bytes benötigt. Unsere Nachricht enthält immer noch eine Kennzahl, die 4 Byte belegt, aber wir können sie problemlos mit einem 8-Bit-Wert mit einem Übertrag komprimieren (möglicherweise ist das Intervall 0-255 zu klein, wir können sicher sein und es auf 9 oder 10 Bit erhöhen). Wie dem auch sei, diese Nachrichten sind recht klein, und dies bedeutet, dass wir in jeder Nachricht viele Eingabedaten senden können (falls die vorherigen Eingabedaten verloren gingen). Wie weit sollten wir zurückgehen? Nun, der Client kennt die Kennzahlnummer der letzten Statusmeldung, die er vom Server erhalten hat. Es macht also keinen Sinn, weiter als diese Kennzahl zurückzugehen. Wir müssen auch die Menge der redundanten Eingabedaten, die vom Client gesendet werden, begrenzen. Ich habe dies in meiner Demo nicht getan, aber es sollte im fertigen Code implementiert sein.
while (this.HasAvailableStateMessage()) { StateMessage state_msg = this.GetStateMessage(); this.client_last_received_state_tick = state_msg.tick_number;
Dies ist eine einfache Änderung. Der Client schreibt einfach die Kennzahl der zuletzt empfangenen Statusmeldung.
Inputs inputs = this.SampleInputs(); InputMessage input_msg; input_msg.start_tick_number = this.client_last_received_state_tick; input_msg.inputs = new List<Inputs>(); for (uint tick = this.client_last_received_state_tick; tick <= this.tick_number; ++tick) { input_msg.inputs.Add(this.client_input_buffer[tick % 1024]); } this.SendToServer(input_msg);
Die vom Client gesendete Eingabenachricht enthält jetzt eine Liste von Eingabedaten, nicht nur ein Element. Der Teil mit der Kennzahl erhält einen neuen Wert - dies ist nun die Kennzahl der ersten Eingabe in dieser Liste.
while (this.HasAvailableInputMessages()) { InputMessage input_msg = this.GetInputMessage();
Wenn der Server eine Eingabenachricht empfängt, kennt er die Kennzahl der ersten Eingabe und die Menge der Eingabedaten in der Nachricht. Daher kann das Maß der letzten Eingabe in der Nachricht berechnet werden. Wenn diese letzte Kennzahl größer oder gleich der Server-Kennzahl ist, weiß sie, dass die Nachricht mindestens eine Eingabe enthält, die der Server noch nicht gesehen hat. Wenn ja, werden alle neuen Eingabedaten simuliert.
Möglicherweise haben Sie bemerkt, dass
bei einer begrenzten Anzahl redundanter Eingabedaten in der Eingabenachricht bei einer ausreichend großen Anzahl verlorener Eingabenachrichten eine Simulationslücke zwischen dem Server und dem Client besteht. Das heißt, der Server kann Maßnahme 100 simulieren, eine Statusnachricht senden, um Maßnahme 101 zu starten, und dann eine Eingangsnachricht ab Maßnahme 105 empfangen. Im obigen Code geht der Server zu 105 über und versucht nicht, Zwischenmaßnahmen basierend auf den neuesten bekannten Eingabedaten zu simulieren. Ob Sie es brauchen, hängt von Ihrer Entscheidung ab und davon, wie das Spiel aussehen soll. Persönlich würde ich den Server aufgrund des schlechten Zustands des Netzwerks nicht zwingen, den Player auf der Karte zu spekulieren und zu bewegen. Ich glaube, dass es besser ist, den Player an Ort und Stelle zu lassen, bis die Verbindung wiederhergestellt ist.
In der Demo „Zen of Networked Physics“ gibt es eine Funktion zum Senden von „wichtigen Bewegungen“ durch den Client, dh er sendet redundante Eingabedaten nur, wenn sie von den zuvor übertragenen Eingaben abweichen. Dies kann als Eingangsdelta-Komprimierung bezeichnet werden, und damit können Sie die Größe der Eingangsnachrichten weiter reduzieren. Aber bisher habe ich es nicht getan, weil es in dieser Demo keine Optimierung der Netzwerklast gibt.
Vor dem Senden redundanter Eingabedaten: Wenn 25% der Pakete verloren gehen, bewegt sich der Cube langsam und zuckt und wird weiterhin zurückgeworfen.Nach dem Senden redundanter Eingabedaten: Bei einem Verlust von 25% der Pakete gibt es immer noch eine Zuckungskorrektur, aber die Cubes bewegen sich mit einer akzeptablen Geschwindigkeit.Variable Schnappschussfrequenz
In dieser Demo variiert die Häufigkeit, mit der der Server Snapshots an den Client sendet. Bei einer reduzierten Häufigkeit benötigt der Client mehr Zeit, um Korrekturen vom Server zu erhalten. Wenn sich der Kunde in der Prognose irrt, kann er daher vor dem Empfang einer Statusmeldung noch mehr abweichen, was zu einer deutlicheren Korrektur führt. Bei einer hohen Häufigkeit von Snapshots ist der Paketverlust viel weniger wichtig, sodass der Client nicht lange auf den Empfang des nächsten Snapshots warten muss.
Schnappschussfrequenz 64 HzSchnappschussfrequenz 16 HzSchnappschussfrequenz 2 HzJe höher die Häufigkeit von Schnappschüssen ist, desto besser. Sie sollten sie daher so oft wie möglich senden. Dies hängt jedoch auch von der Menge des zusätzlichen Datenverkehrs, seinen Kosten, der Verfügbarkeit dedizierter Server, den Rechenkosten von Servern usw. ab.
Glättungskorrektur
Wir erstellen falsche Prognosen und erhalten häufiger ruckartige Korrekturen, als wir möchten. Ohne ordnungsgemäßen Zugriff auf die Unity / PhysX-Integration kann ich diese fehlerhaften Prognosen kaum debuggen. Ich habe das schon einmal gesagt, aber ich wiederhole es noch einmal - wenn Sie etwas in Bezug auf Physik finden, in dem ich falsch liege, dann lassen Sie es mich wissen.
Ich habe die Lösung für dieses Problem umgangen, indem ich die Risse mit guter alter Glättung beschönigt habe! Wenn eine Korrektur erfolgt, glättet der Client einfach die Position und Drehung des Players in Richtung des richtigen Zustands für mehrere Frames. Der physische Würfel selbst wird sofort korrigiert (er ist unsichtbar), aber wir haben nur einen zweiten Würfel zur Anzeige, der eine Glättung ermöglicht.
Vector3 position_error = state_msg.position - predicted_state.position; float rotation_error = 1.f - Quaternion.Dot(state_msg.rotation, predicted_state.rotation); if (position_error.sqrMagnitude > 0.0000001f || rotation_error > 0.00001f) { Rigidbody player_rigidbody = player.GetComponent<Rigidbody>();
Wenn eine fehlerhafte Vorhersage auftritt, verfolgt der Client die Positions- / Rotationsdifferenz nach der Korrektur. Wenn der Gesamtabstand der Positionskorrektur mehr als 2 Meter beträgt, bewegt sich der Würfel einfach ruckartig - die Glättung würde immer noch schlecht aussehen. Lassen Sie ihn also zumindest so schnell wie möglich in den richtigen Zustand zurückkehren.
this.client_pos_error *= 0.9f; this.client_rot_error = Quaternion.Slerp(this.client_rot_error, Quaternion.identity, 0.1f); this.smoothed_client_player.transform.position = player_rigidbody.position + this.client_pos_error; this.smoothed_client_player.transform.rotation = player_rigidbody.rotation * this.client_rot_error;
In jedem Frame führt der Client Lerp / Slerp in Richtung der richtigen Position / Drehung um 10% aus. Dies ist ein Standardansatz nach dem Potenzgesetz zur Mittelung der Bewegung. Es hängt von der Bildrate ab, aber für die Zwecke unserer Demo ist dies völlig ausreichend.
250 ms Verzögerung
10% der Pakete verloren
Ohne Glättung ist die Korrektur sehr auffällig250 ms Verzögerung
10% der Pakete verloren
Bei der Glättung ist die Korrektur viel schwieriger zu bemerken.Das Endergebnis funktioniert ziemlich gut. Ich möchte eine Version erstellen, die wirklich Pakete sendet, anstatt sie nachzuahmen. Zumindest ist dies jedoch ein Proof of Concept für ein clientseitiges Prognosesystem mit realen physischen Objekten in Unity, ohne dass physische Plug-Ins und dergleichen erforderlich sind.