Physik für einen mobilen PvP-Shooter oder wie wir ein zweidimensionales Spiel in ein dreidimensionales verwandeln



In einem früheren Artikel sprach mein Kollege darüber, wie wir in unserem mobilen Multiplayer-Shooter eine zweidimensionale Physik-Engine verwendet haben. Und jetzt möchte ich Ihnen mitteilen, wie wir alles, was wir zuvor getan haben, von vorne begonnen haben - mit anderen Worten, wie wir unser Spiel von der 2D-Welt in die 3D-Welt übertragen haben.

Alles begann damit, dass einmal ein Produzent und ein führender Spieledesigner in die Programmierabteilung kamen und uns vor eine Herausforderung stellten: Ein mobiler PvP-Top-Down-Shooter mit Dreharbeiten auf engstem Raum musste in einen Third-Person-Shooter mit Dreharbeiten auf freiem Feld umgewandelt werden. In diesem Fall ist es wünschenswert, dass die Karte nicht so aussieht:



Und so:



Die technischen Anforderungen waren wie folgt:

  • Kartengröße - 100 × 100 Meter;
  • Höhenunterschied - 40 Meter;
  • Unterstützung für Tunnel, Brücken;
  • Schießen auf Ziele in verschiedenen Höhen;
  • Kollisionen mit statischer Geometrie (wir haben keine Kollisionen mit anderen Charakteren im Spiel);
  • Physik des freien Falls;
  • Granatenwurf Physik.

Mit Blick auf die Zukunft kann ich sagen, dass unser Spiel nicht wie der letzte Screenshot aussah: Es stellte sich heraus, dass es eine Kreuzung zwischen der ersten und der zweiten Option war.

Option eins: geschichtete Struktur


Die erste Idee bestand darin, die Physik-Engine nicht zu ändern, sondern einfach mehrere Ebenen mit "Anzahl der Stockwerke" hinzuzufügen. Es stellte sich so etwas wie Grundrisse im Gebäude heraus:



Mit diesem Ansatz mussten wir weder die Client- noch die Serveranwendungen radikal wiederholen, und im Allgemeinen schien es, dass die Aufgabe auf diese Weise ganz einfach gelöst wurde. Bei der Implementierung sind jedoch einige kritische Probleme aufgetreten:

  1. Nachdem wir die Details mit Level-Designern geklärt hatten, kamen wir zu dem Schluss, dass die Anzahl der „Stockwerke“ in einem solchen Schema beeindruckend sein kann: Einige der Karten befinden sich in einem offenen Gebiet mit sanften Hängen und Hügeln.
  2. Die Berechnung von Treffern beim Schießen von einer Ebene zur anderen wurde zu einer nicht trivialen Aufgabe. Ein Beispiel für eine Problemsituation ist in der folgenden Abbildung dargestellt: Hier kann Spieler 1 in Spieler 3 gelangen, jedoch nicht in Spieler 2, da der Schusspfad Schicht 2 blockiert, obwohl sich Spieler 2 und Spieler 3 auf derselben Schicht befinden.



Kurz gesagt, wir haben die Idee der Aufteilung des Raums in 2D-Ebenen schnell aufgegeben und beschlossen, die physische Engine vollständig zu ersetzen.

Aus diesem Grund mussten wir genau diese Engine auswählen und in vorhandene Client- und Serveranwendungen integrieren.

Option 2: Wählen Sie eine fertige Bibliothek aus


Da der Spielclient in Unity geschrieben ist, haben wir beschlossen, die Möglichkeit der Verwendung der in Unity standardmäßig integrierten physischen Engine - PhysX - in Betracht zu ziehen. Im Allgemeinen erfüllte er die Anforderungen unserer Spieleentwickler, um die 3D-Physik im Spiel zu unterstützen, voll und ganz, aber es gab immer noch ein erhebliches Problem. Es bestand darin, dass unsere Serveranwendung in C # geschrieben war, ohne Unity zu verwenden.

Es gab die Möglichkeit, eine C ++ - Bibliothek auf einem Server zu verwenden - zum Beispiel auf demselben PhysX -, aber wir haben dies nicht ernsthaft in Betracht gezogen: Aufgrund der Verwendung von nativem Code war die Wahrscheinlichkeit von Serverabstürzen bei diesem Ansatz hoch. Ebenfalls peinlich ist die geringe Produktivität der Interop-Vorgänge und die Einzigartigkeit der PhysX-Assembly, die ausschließlich unter Unity ausgeführt wird, mit Ausnahme der Verwendung in einer anderen Umgebung.

Darüber hinaus wurden bei dem Versuch, diese Idee umzusetzen, andere Probleme festgestellt:

  • Die fehlende Unterstützung für die Erstellung von Unity mit IL2CPP unter Linux erwies sich als sehr kritisch, da wir in einer der neuesten Versionen unsere Spieleserver auf .Net Core 2.1 umgestellt und auf Linux-Computern bereitgestellt haben.
  • Mangel an praktischen Werkzeugen für die Profilerstellung von Servern in Unity;
  • Geringe Leistung der Unity-Anwendung: Wir benötigten nur eine physische Engine und nicht alle verfügbaren Funktionen in Unity.

Parallel zu unserem Projekt entwickelte das Unternehmen einen weiteren Prototyp eines Multiplayer-PvP-Spiels. Die Entwickler verwendeten Unity-Server, und wir bekamen ziemlich viele negative Rückmeldungen bezüglich des vorgeschlagenen Ansatzes. Eine der Beschwerden war insbesondere, dass die Unity-Server sehr "gestreamt" wurden und alle paar Stunden neu gestartet werden mussten.

Die Kombination dieser Probleme hat uns auch dazu gebracht, diese Idee aufzugeben. Dann haben wir beschlossen, die Spieleserver auf .Net Core 2.1 zu belassen und statt VolatilePhysics, das wir zuvor verwendet haben, eine andere in C # geschriebene offene physische Engine zu wählen. Wir brauchten nämlich eine C # -Engine, da wir bei der Verwendung von in C ++ geschriebenen Engines Angst vor unerwarteten Abstürzen hatten.

Als Ergebnis wurden die folgenden Motoren für Tests ausgewählt:


Das Hauptkriterium für uns war die Leistung der Engine, die Möglichkeit ihrer Integration in Unity und ihre Unterstützung: Sie hätte nicht aufgegeben werden dürfen, wenn wir Fehler darin gefunden hätten.

Daher haben wir die Bepu Physics v1-, Bepu Physics v2- und Jitter Physics-Engines auf Leistung getestet, und unter ihnen erwies sich Bepu Physics v2 als die produktivste. Außerdem ist er der einzige von diesen dreien, der sich weiterhin aktiv entwickelt.

Bepu Physics v2 erfüllte jedoch nicht das letzte verbleibende Integrationskriterium für Unity: Diese Bibliothek verwendet SIMD-Operationen und System.Numerics. Da Baugruppen auf Mobilgeräten mit IL2CPP keine SIMD-Unterstützung bieten, gingen alle Vorteile der Bepu-Optimierungen verloren. Die Demoszene im Build für iOS auf dem iPhone 5S war sehr langsam. Wir konnten diese Lösung nicht auf Mobilgeräten verwenden.

Hier sollte erklärt werden, warum wir generell an der Verwendung einer physikalischen Maschine interessiert waren. In einem meiner vorherigen Artikel habe ich darüber gesprochen, wie wir den Netzwerkteil des Spiels implementiert haben und wie die lokale Vorhersage von Spieleraktionen funktioniert. Kurz gesagt, derselbe Code wird auf dem Client und dem Server ausgeführt - dem ECS-System. Der Client reagiert sofort auf die Aktionen des Spielers, ohne auf eine Antwort vom Server zu warten - die sogenannte Vorhersage erfolgt. Wenn eine Antwort vom Server kommt, überprüft der Client den vorhergesagten Zustand der Welt mit dem empfangenen und wenn sie nicht übereinstimmen (falsche Vorhersage), wird basierend auf der Antwort vom Server eine Korrektur (Abstimmung) dessen durchgeführt, was der Spieler sieht.

Die Hauptidee ist, dass wir denselben Code sowohl auf dem Client als auch auf dem Server ausführen und Situationen mit falscher Vorhersage äußerst selten sind. Keine der physischen C # -Engines, die wir gefunden haben, erfüllte jedoch unsere Anforderungen bei der Arbeit mit Mobilgeräten: Beispielsweise konnte sie auf dem iPhone 5S keine stabilen 30 fps liefern.

Option drei, endgültig: zwei verschiedene Motoren


Dann beschlossen wir zu experimentieren: Verwenden Sie zwei verschiedene physische Engines auf dem Client und dem Server. Wir dachten, dass dies in unserem Fall funktionieren könnte: Wir haben eine ziemlich einfache Kollisionsphysik in unserem Spiel, außerdem wurde sie von uns als separates ECS-System implementiert und war nicht Teil der physischen Engine. Alles, was wir von der physischen Engine benötigten, war die Fähigkeit, Reykast- und Sweepcasts im 3D-Raum zu erstellen.

Aus diesem Grund haben wir uns entschlossen, die integrierte Physics Unity - PhysX - auf dem Client und Bepu Physics v2 auf dem Server zu verwenden.

Zunächst haben wir die Benutzeroberfläche für die Verwendung der physischen Engine hervorgehoben:

Code anzeigen
using System; using System.Collections.Generic; using System.Numerics; namespace Prototype.Common.Physics { public interface IPhysicsWorld : IDisposable { bool HasBody(uint id); void SetCurrentSimulationTick(int tick); void Update(); RayCastHit RayCast(Vector3 origin, Vector3 direction, float distance, CollisionLayer layer, int ticksBehind = 0, List<uint> ignoreIds = null); RayCastHit SphereCast(Vector3 origin, Vector3 direction, float distance, float radius, CollisionLayer layer, int ticksBehind = 0, List<uint> ignoreIds = null); RayCastHit CapsuleCast(Vector3 origin, Vector3 direction, float distance, float radius, float height, CollisionLayer layer, int ticksBehind = 0, List<uint> ignoreIds = null); void CapsuleOverlap(Vector3 origin, float radius, float height, BodyMobilityField bodyMobilityField, CollisionLayer layer, List<Overlap> overlaps, int ticksBehind = 0); void RemoveOrphanedDynamicBodies(WorldState.TableSet currentWorld); void UpdateBody(uint id, Vector3 position, float angle); void CreateStaticCapsule(Vector3 origin, Quaternion rotation, float radius, float height, uint id, CollisionLayer layer); void CreateDynamicCapsule(Vector3 origin, Quaternion rotation, float radius, float height, uint id, CollisionLayer layer); void CreateStaticBox(Vector3 origin, Quaternion rotation, Vector3 size, uint id, CollisionLayer layer); void CreateDynamicBox(Vector3 origin, Quaternion rotation, Vector3 size, uint id, CollisionLayer layer); } } 


Es gab verschiedene Implementierungen dieser Schnittstelle auf dem Client und dem Server: Wie bereits erwähnt, verwendeten wir auf dem Server die Implementierung mit Bepu und auf dem Client - Unity.

An dieser Stelle sei auf die Feinheiten der Arbeit mit unserer Physik auf dem Server hingewiesen.

Aufgrund der Tatsache, dass der Client Weltaktualisierungen mit Verzögerung (Lag) vom Server erhält, sieht der Spieler die Welt ein wenig anders als das, was er auf dem Server sieht: Er sieht sich in der Gegenwart und den Rest der Welt in der Vergangenheit. Aus diesem Grund hat sich herausgestellt, dass der Spieler lokal auf ein Ziel schießt, das sich auf dem Server an einer anderen Stelle befindet. Da wir also das lokale System zur Vorhersage von Spieleraktionen verwenden, müssen wir die Verzögerungen beim Schießen auf dem Server ausgleichen.



Um dies auszugleichen, müssen wir die Geschichte der Welt für die letzten N Millisekunden auf dem Server speichern und in der Lage sein, mit Objekten aus der Geschichte einschließlich ihrer Physik zu arbeiten. Das heißt, unser System muss in der Lage sein, Kollisionen, Rakcasts und Sweepcasts „in der Vergangenheit“ zu berechnen. Physikalische Engines können dies in der Regel nicht und Bepu mit PhysX ist keine Ausnahme. Daher mussten wir diese Funktionalität selbst implementieren.

Da wir das Spiel mit einer festen Frequenz von 30 Ticks pro Sekunde simulieren, mussten wir für jeden Tick die Daten der physischen Welt speichern. Die Idee war, nicht eine Instanz der Simulation in der physischen Maschine zu erstellen, sondern N - für jeden in der Historie gespeicherten Tick - und den zyklischen Puffer dieser Simulationen zu verwenden, um sie in der Historie zu speichern:

 private readonly SimulationSlice[] _simulationHistory = new SimulationSlice[PhysicsConfigs.HistoryLength]; public BepupPhysicsWorld() { _currentSimulationTick = 1; for (int i = 0; i < PhysicsConfigs.HistoryLength; i++) { _simulationHistory[i] = new SimulationSlice(_bufferPool); } } 

In unserem ECS gibt es eine Reihe von Schreib- / Lesesystemen, die mit der Physik arbeiten:

  • InitPhysicsWorldSystem;
  • SpawnPhysicsDynamicsBodiesSystem;
  • DestroyPhysicsDynamicsBodiesSystem;
  • UpdatePhysicsTransformsSystem;
  • MovePhysicsSystem,

sowie eine Reihe von Nur-Lese-Systemen, z. B. ein System zur Berechnung von Treffern aus Schüssen, Explosionen aus Granaten usw.

Bei jedem Tick der Weltsimulation wird zuerst das InitPhysicsWorldSystem ausgeführt, das die aktuelle Tick-Nummer (SimulationSlice) auf die physische Engine setzt:

 public void SetCurrentSimulationTick(int tick) { var oldTick = tick - 1; var newSlice = _simulationHistory[tick % PhysicsConfigs.HistoryLength]; var oldSlice = _simulationHistory[oldTick % PhysicsConfigs.HistoryLength]; newSlice.RestoreBodiesFromPreviousTick(oldSlice); _currentSimulationTick = tick; } 

Die RestoreBodiesFromPreviousTick-Methode stellt die Position von Objekten in der physischen Engine zum Zeitpunkt des vorherigen Ticks aus den im Verlauf gespeicherten Daten wieder her:

Code anzeigen
 public void RestoreBodiesFromPreviousTick(SimulationSlice previous) { var oldStaticCount = previous._staticIds.Count; // add created static objects for (int i = 0; i < oldStaticCount; i++) { var oldId = previous._staticIds[i]; if (!_staticIds.Contains(oldId)) { var oldHandler = previous._staticIdToHandler[oldId]; var oldBody = previous._staticHandlerToBody[oldHandler]; if (oldBody.IsCapsule) { var handler = CreateStatic(oldBody.Capsule, oldBody.Description.Pose, true, oldId, oldBody.CollisionLayer); var body = _staticHandlerToBody[handler]; body.Capsule = oldBody.Capsule; _staticHandlerToBody[handler] = body; } else { var handler = CreateStatic(oldBody.Box, oldBody.Description.Pose, false, oldId, oldBody.CollisionLayer); var body = _staticHandlerToBody[handler]; body.Box = oldBody.Box; _staticHandlerToBody[handler] = body; } } } // delete not existing dynamic objects var newDynamicCount = _dynamicIds.Count; var idsToDel = stackalloc uint[_dynamicIds.Count]; int delIndex = 0; for (int i = 0; i < newDynamicCount; i++) { var newId = _dynamicIds[i]; if (!previous._dynamicIds.Contains(newId)) { idsToDel[delIndex] = newId; delIndex++; } } for (int i = 0; i < delIndex; i++) { var id = idsToDel[i]; var handler = _dynamicIdToHandler[id]; _simulation.Bodies.Remove(handler); _dynamicHandlerToBody.Remove(handler); _dynamicIds.Remove(id); _dynamicIdToHandler.Remove(id); } // add created dynamic objects var oldDynamicCount = previous._dynamicIds.Count; for (int i = 0; i < oldDynamicCount; i++) { var oldId = previous._dynamicIds[i]; if (!_dynamicIds.Contains(oldId)) { var oldHandler = previous._dynamicIdToHandler[oldId]; var oldBody = previous._dynamicHandlerToBody[oldHandler]; if (oldBody.IsCapsule) { var handler = CreateDynamic(oldBody.Capsule, oldBody.BodyReference.Pose, true, oldId, oldBody.CollisionLayer); var body = _dynamicHandlerToBody[handler]; body.Capsule = oldBody.Capsule; _dynamicHandlerToBody[handler] = body; } else { var handler = CreateDynamic(oldBody.Box, oldBody.BodyReference.Pose, false, oldId, oldBody.CollisionLayer); var body = _dynamicHandlerToBody[handler]; body.Box = oldBody.Box; _dynamicHandlerToBody[handler] = body; } } } } 


Danach erstellen oder löschen die Systeme SpawnPhysicsDynamicsBodiesSystem und DestroyPhysicsDynamicsBodiesSystem Objekte in der physischen Engine, je nachdem, wie sie im letzten ECS-Tick geändert wurden. Anschließend aktualisiert das UpdatePhysicsTransformsSystem die Position aller dynamischen Körper gemäß den Daten in ECS.

Sobald die Daten im ECS und in der Physik-Engine synchronisiert sind, berechnen wir die Bewegung von Objekten. Wenn alle Lese- / Schreibvorgänge abgeschlossen sind, kommen Nur-Lese-Systeme zur Berechnung der Spiellogik (Schüsse, Explosionen, Nebel des Krieges ...) ins Spiel.

Vollständiger Implementierungscode für SimulationSlice für Bepu Physics:

Code anzeigen
 using System; using System.Collections.Generic; using System.Numerics; using BepuPhysics; using BepuPhysics.Collidables; using BepuUtilities.Memory; using Quaternion = BepuUtilities.Quaternion; namespace Prototype.Physics { public partial class BepupPhysicsWorld { private unsafe partial class SimulationSlice : IDisposable { private readonly Dictionary<int, StaticBody> _staticHandlerToBody = new Dictionary<int, StaticBody>(); private readonly Dictionary<int, DynamicBody> _dynamicHandlerToBody = new Dictionary<int, DynamicBody>(); private readonly Dictionary<uint, int> _staticIdToHandler = new Dictionary<uint, int>(); private readonly Dictionary<uint, int> _dynamicIdToHandler = new Dictionary<uint, int>(); private readonly List<uint> _staticIds = new List<uint>(); private readonly List<uint> _dynamicIds = new List<uint>(); private readonly BufferPool _bufferPool; private readonly Simulation _simulation; public SimulationSlice(BufferPool bufferPool) { _bufferPool = bufferPool; _simulation = Simulation.Create(_bufferPool, new NarrowPhaseCallbacks(), new PoseIntegratorCallbacks(new Vector3(0, -9.81f, 0))); } public RayCastHit RayCast(Vector3 origin, Vector3 direction, float distance, CollisionLayer layer, List<uint> ignoreIds=null) { direction = direction.Normalized(); BepupRayCastHitHandler handler = new BepupRayCastHitHandler(_staticHandlerToBody, _dynamicHandlerToBody, layer, ignoreIds); _simulation.RayCast(origin, direction, distance, ref handler); var result = handler.RayCastHit; if (result.IsValid) { var collidableReference = handler.CollidableReference; if (handler.CollidableReference.Mobility == CollidableMobility.Static) { _simulation.Statics.GetDescription(collidableReference.Handle, out var description); result.HitEntityId = _staticHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = description.Pose.Position; } else { _simulation.Bodies.GetDescription(collidableReference.Handle, out var description); result.HitEntityId = _dynamicHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = description.Pose.Position; } } return result; } public RayCastHit SphereCast(Vector3 origin, Vector3 direction, float distance, float radius, CollisionLayer layer, List<uint> ignoreIds = null) { direction = direction.Normalized(); SweepCastHitHandler handler = new SweepCastHitHandler(_staticHandlerToBody, _dynamicHandlerToBody, layer, ignoreIds); _simulation.Sweep(new Sphere(radius), new RigidPose(origin, Quaternion.Identity), new BodyVelocity(direction.Normalized()), distance, _bufferPool, ref handler); var result = handler.RayCastHit; if (result.IsValid) { var collidableReference = handler.CollidableReference; if (handler.CollidableReference.Mobility == CollidableMobility.Static) { _simulation.Statics.GetDescription(collidableReference.Handle, out var description); result.HitEntityId = _staticHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = description.Pose.Position; } else { var reference = new BodyReference(collidableReference.Handle, _simulation.Bodies); result.HitEntityId = _dynamicHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = reference.Pose.Position; } } return result; } public RayCastHit CapsuleCast(Vector3 origin, Vector3 direction, float distance, float radius, float height, CollisionLayer layer, List<uint> ignoreIds = null) { direction = direction.Normalized(); var length = height - 2 * radius; SweepCastHitHandler handler = new SweepCastHitHandler(_staticHandlerToBody, _dynamicHandlerToBody, layer, ignoreIds); _simulation.Sweep(new Capsule(radius, length), new RigidPose(origin, Quaternion.Identity), new BodyVelocity(direction.Normalized()), distance, _bufferPool, ref handler); var result = handler.RayCastHit; if (result.IsValid) { var collidableReference = handler.CollidableReference; if (handler.CollidableReference.Mobility == CollidableMobility.Static) { _simulation.Statics.GetDescription(collidableReference.Handle, out var description); result.HitEntityId = _staticHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = description.Pose.Position; } else { var reference = new BodyReference(collidableReference.Handle, _simulation.Bodies); result.HitEntityId = _dynamicHandlerToBody[collidableReference.Handle].Id; result.CollidableCenter = reference.Pose.Position; } } return result; } public void CapsuleOverlap(Vector3 origin, float radius, float height, BodyMobilityField bodyMobilityField, CollisionLayer layer, List<Overlap> overlaps) { var length = height - 2 * radius; var handler = new BepupOverlapHitHandler( bodyMobilityField, layer, _staticHandlerToBody, _dynamicHandlerToBody, overlaps); _simulation.Sweep( new Capsule(radius, length), new RigidPose(origin, Quaternion.Identity), new BodyVelocity(Vector3.Zero), 0, _bufferPool, ref handler); } public void CreateDynamicBox(Vector3 origin, Quaternion rotation, Vector3 size, uint id, CollisionLayer layer) { var shape = new Box(size.X, size.Y, size.Z); var pose = new RigidPose() { Position = origin, Orientation = rotation }; var handler = CreateDynamic(shape, pose, false, id, layer); var body = _dynamicHandlerToBody[handler]; body.Box = shape; _dynamicHandlerToBody[handler] = body; } public void CreateStaticBox(Vector3 origin, Quaternion rotation, Vector3 size, uint id, CollisionLayer layer) { var shape = new Box(size.X, size.Y, size.Z); var pose = new RigidPose() { Position = origin, Orientation = rotation }; var handler =CreateStatic(shape, pose, false, id, layer); var body = _staticHandlerToBody[handler]; body.Box = shape; _staticHandlerToBody[handler] = body; } public void CreateStaticCapsule(Vector3 origin, Quaternion rotation, float radius, float height, uint id, CollisionLayer layer) { var length = height - 2 * radius; var shape = new Capsule(radius, length); var pose = new RigidPose() { Position = origin, Orientation = rotation }; var handler =CreateStatic(shape, pose, true, id, layer); var body = _staticHandlerToBody[handler]; body.Capsule = shape; _staticHandlerToBody[handler] = body; } public void CreateDynamicCapsule(Vector3 origin, Quaternion rotation, float radius, float height, uint id, CollisionLayer layer) { var length = height - 2 * radius; var shape = new Capsule(radius, length); var pose = new RigidPose() { Position = origin, Orientation = rotation }; var handler = CreateDynamic(shape, pose, true, id, layer); var body = _dynamicHandlerToBody[handler]; body.Capsule = shape; _dynamicHandlerToBody[handler] = body; } private int CreateDynamic<TShape>(TShape shape, RigidPose pose, bool isCapsule, uint id, CollisionLayer collisionLayer) where TShape : struct, IShape { var activity = new BodyActivityDescription() { SleepThreshold = -1 }; var collidable = new CollidableDescription() { Shape = _simulation.Shapes.Add(shape), SpeculativeMargin = 0.1f, }; var capsuleDescription = BodyDescription.CreateKinematic(pose, collidable, activity); var handler = _simulation.Bodies.Add(capsuleDescription); _dynamicIds.Add(id); _dynamicIdToHandler.Add(id, handler); _dynamicHandlerToBody.Add(handler, new DynamicBody { BodyReference = new BodyReference(handler, _simulation.Bodies), Id = id, IsCapsule = isCapsule, CollisionLayer = collisionLayer }); return handler; } private int CreateStatic<TShape>(TShape shape, RigidPose pose, bool isCapsule, uint id, CollisionLayer collisionLayer) where TShape : struct, IShape { var capsuleDescription = new StaticDescription() { Pose = pose, Collidable = new CollidableDescription() { Shape = _simulation.Shapes.Add(shape), SpeculativeMargin = 0.1f, } }; var handler = _simulation.Statics.Add(capsuleDescription); _staticIds.Add(id); _staticIdToHandler.Add(id, handler); _staticHandlerToBody.Add(handler, new StaticBody { Description = capsuleDescription, Id = id, IsCapsule = isCapsule, CollisionLayer = collisionLayer }); return handler; } public void RemoveOrphanedDynamicBodies(TableSet currentWorld) { var toDel = stackalloc uint[_dynamicIds.Count]; var toDelIndex = 0; foreach (var i in _dynamicIdToHandler) { if (currentWorld.DynamicPhysicsBody.HasCmp(i.Key)) { continue; } toDel[toDelIndex] = i.Key; toDelIndex++; } for (int i = 0; i < toDelIndex; i++) { var id = toDel[i]; var handler = _dynamicIdToHandler[id]; _simulation.Bodies.Remove(handler); _dynamicHandlerToBody.Remove(handler); _dynamicIds.Remove(id); _dynamicIdToHandler.Remove(id); } } public bool HasBody(uint id) { return _staticIdToHandler.ContainsKey(id) || _dynamicIdToHandler.ContainsKey(id); } public void RestoreBodiesFromPreviousTick(SimulationSlice previous) { var oldStaticCount = previous._staticIds.Count; // add created static objects for (int i = 0; i < oldStaticCount; i++) { var oldId = previous._staticIds[i]; if (!_staticIds.Contains(oldId)) { var oldHandler = previous._staticIdToHandler[oldId]; var oldBody = previous._staticHandlerToBody[oldHandler]; if (oldBody.IsCapsule) { var handler = CreateStatic(oldBody.Capsule, oldBody.Description.Pose, true, oldId, oldBody.CollisionLayer); var body = _staticHandlerToBody[handler]; body.Capsule = oldBody.Capsule; _staticHandlerToBody[handler] = body; } else { var handler = CreateStatic(oldBody.Box, oldBody.Description.Pose, false, oldId, oldBody.CollisionLayer); var body = _staticHandlerToBody[handler]; body.Box = oldBody.Box; _staticHandlerToBody[handler] = body; } } } // delete not existing dynamic objects var newDynamicCount = _dynamicIds.Count; var idsToDel = stackalloc uint[_dynamicIds.Count]; int delIndex = 0; for (int i = 0; i < newDynamicCount; i++) { var newId = _dynamicIds[i]; if (!previous._dynamicIds.Contains(newId)) { idsToDel[delIndex] = newId; delIndex++; } } for (int i = 0; i < delIndex; i++) { var id = idsToDel[i]; var handler = _dynamicIdToHandler[id]; _simulation.Bodies.Remove(handler); _dynamicHandlerToBody.Remove(handler); _dynamicIds.Remove(id); _dynamicIdToHandler.Remove(id); } // add created dynamic objects var oldDynamicCount = previous._dynamicIds.Count; for (int i = 0; i < oldDynamicCount; i++) { var oldId = previous._dynamicIds[i]; if (!_dynamicIds.Contains(oldId)) { var oldHandler = previous._dynamicIdToHandler[oldId]; var oldBody = previous._dynamicHandlerToBody[oldHandler]; if (oldBody.IsCapsule) { var handler = CreateDynamic(oldBody.Capsule, oldBody.BodyReference.Pose, true, oldId, oldBody.CollisionLayer); var body = _dynamicHandlerToBody[handler]; body.Capsule = oldBody.Capsule; _dynamicHandlerToBody[handler] = body; } else { var handler = CreateDynamic(oldBody.Box, oldBody.BodyReference.Pose, false, oldId, oldBody.CollisionLayer); var body = _dynamicHandlerToBody[handler]; body.Box = oldBody.Box; _dynamicHandlerToBody[handler] = body; } } } } public void Update() { _simulation.Timestep(GameState.TickDurationSec); } public void UpdateBody(uint id, Vector3 position, float angle) { if (_staticIdToHandler.TryGetValue(id, out var handler)) { _simulation.Statics.GetDescription(handler, out var staticDescription); staticDescription.Pose.Position = position; staticDescription.Pose.Orientation = Quaternion.CreateFromAxisAngle(new Vector3(0, 1, 0), angle); _simulation.Statics.ApplyDescription(handler, staticDescription); } else if(_dynamicIdToHandler.TryGetValue(id, out handler)) { BodyReference reference = new BodyReference(handler, _simulation.Bodies); reference.Pose.Position = position; reference.Pose.Orientation = Quaternion.CreateFromAxisAngle(new Vector3(0, 1, 0), angle); } } public void Dispose() { _simulation.Clear(); } } public void Dispose() { _bufferPool.Clear(); } } } 


Zusätzlich zur Implementierung des Verlaufs auf dem Server mussten wir auch den Verlauf der Physik auf dem Client implementieren. Unser Unity-Client verfügt über einen Server-Emulationsmodus (wir nennen ihn lokale Simulation), in dem der Servercode mit dem Client ausgeführt wird. Wir verwenden diesen Modus für das Rapid Prototyping von Spielfunktionen.

Wie Bepu hat PhysX keine Unterstützung für die Historie. Hier haben wir dieselbe Idee angewendet, bei der für jeden Tick im Verlauf mehrere physikalische Simulationen wie auf dem Server verwendet wurden. Bei der Arbeit mit physischen Engines legt Unity jedoch seine eigenen Besonderheiten fest. Es ist jedoch zu beachten, dass unser Projekt auf Unity 2018.4 (LTS) entwickelt wurde und einige APIs in neueren Versionen möglicherweise geändert werden, sodass es keine Probleme wie unsere geben wird.

Das Problem bestand darin, dass in Unity keine separate physikalische Simulation (oder in der PhysX-Terminologie eine Szene) erstellt werden konnte. Daher haben wir jeden Tick in der Geschichte der Physik in Unity als separate Szene implementiert.

Über solche Szenen wurde eine Wrapper-Klasse geschrieben - UnityPhysicsHistorySlice:

 public UnityPhysicsHistorySlice(SphereCastDelegate sphereCastDelegate, OverlapSphereNonAlloc overlapSphere, CapsuleCastDelegate capsuleCast, OverlapCapsuleNonAlloc overlapCapsule, string name) { _scene = SceneManager.CreateScene(name, new CreateSceneParameters() { localPhysicsMode = LocalPhysicsMode.Physics3D }); _physicsScene = _scene.GetPhysicsScene(); _sphereCast = sphereCastDelegate; _capsuleCast = capsuleCast; _overlapSphere = overlapSphere; _overlapCapsule = overlapCapsule; _boxPool = new PhysicsSceneObjectsPool<BoxCollider>(_scene, "box", 0); _capsulePool = new PhysicsSceneObjectsPool<UnityEngine.CapsuleCollider>(_scene, "sphere", 0); } 

Das zweite Problem von Unity ist, dass alle Arbeiten mit Physik über die statische Klasse Physik ausgeführt werden, deren API es Ihnen nicht ermöglicht, Rakecasts und Sweepcasts in einer bestimmten Szene durchzuführen. Diese API funktioniert nur mit einer aktiven Szene. Die PhysX-Engine selbst ermöglicht es Ihnen jedoch, mit mehreren Szenen gleichzeitig zu arbeiten. Sie müssen nur die richtigen Methoden aufrufen. Glücklicherweise versteckte Unity solche Methoden hinter der Physics.cs-Klassenschnittstelle. Alles, was übrig blieb, war der Zugriff darauf. Wir haben es so gemacht:

Code anzeigen
 MethodInfo raycastMethod = typeof(Physics).GetMethod("Internal_SphereCast", BindingFlags.NonPublic | BindingFlags.Static); var sphereCast = (SphereCastDelegate) Delegate.CreateDelegate(typeof(SphereCastDelegate), raycastMethod); MethodInfo overlapSphereMethod = typeof(Physics).GetMethod("OverlapSphereNonAlloc_Internal", BindingFlags.NonPublic | BindingFlags.Static); var overlapSphere = (OverlapSphereNonAlloc) Delegate.CreateDelegate(typeof(OverlapSphereNonAlloc), overlapSphereMethod); MethodInfo capsuleCastMethod = typeof(Physics).GetMethod("Internal_CapsuleCast", BindingFlags.NonPublic | BindingFlags.Static); var capsuleCast = (CapsuleCastDelegate) Delegate.CreateDelegate(typeof(CapsuleCastDelegate), capsuleCastMethod); MethodInfo overlapCapsuleMethod = typeof(Physics).GetMethod("OverlapCapsuleNonAlloc_Internal", BindingFlags.NonPublic | BindingFlags.Static); var overlapCapsule = (OverlapCapsuleNonAlloc) Delegate.CreateDelegate(typeof(OverlapCapsuleNonAlloc), overlapCapsuleMethod); 


Ansonsten unterschied sich der Code zum Implementieren von UnityPhysicsHistorySlice nicht wesentlich von dem in BepuSimulationSlice.

Somit haben wir zwei Implementierungen der Spielphysik: auf dem Client und auf dem Server.

Der nächste Schritt ist das Testen.

Einer der wichtigsten Indikatoren für die „Gesundheit“ unserer Kunden ist der Parameter für die Anzahl der Fehlvorhersagen beim Server. Vor dem Umstieg auf andere physische Engines variierte dieser Indikator innerhalb von 1-2% - das heißt, während eines Kampfes von 9000 Ticks (oder 5 Minuten) wurden 90-180 Simulations-Ticks verwechselt. Wir haben diese Ergebnisse über mehrere Releases des Spiels in der Soft Lounge erhalten. Nachdem wir auf verschiedene Engines umgestellt hatten, erwarteten wir ein starkes Wachstum dieses Indikators - vielleicht sogar mehrmals - schließlich führten wir auf Client und Server unterschiedlichen Code aus, und es schien logisch, dass sich die Fehler in den Berechnungen durch verschiedene Algorithmen schnell ansammeln würden. In der Praxis stellte sich heraus, dass der Diskrepanzparameter nur um 0,2-0,5% wuchs und durchschnittlich 2-2,5% pro Gefecht betrug, was für uns völlig angemessen war.

Die meisten von uns untersuchten Engines und Technologien verwendeten auf Client und Server denselben Code. Unsere Hypothese mit der Möglichkeit der Verwendung unterschiedlicher physikalischer Motoren wurde jedoch bestätigt. Der Hauptgrund, warum die Diskrepanzrate so leicht angestiegen ist, war, dass wir die Bewegung von Körpern im Weltraum und Kollisionen mit einem unserer ECS-Systeme berechnen. Dieser Code ist sowohl auf dem Client als auch auf dem Server identisch. Bei der physischen Maschine brauchten wir eine schnelle Berechnung von Rakecasts und Sweepcasts, und die Ergebnisse dieser Operationen in der Praxis unterschieden sich für unsere beiden Maschinen nicht wesentlich.

Was zu lesen


Abschließend, wie üblich, hier ein paar verwandte Links:

Source: https://habr.com/ru/post/de485150/


All Articles