Physik für einen mobilen PvP-Shooter und wie wir uns mit ECS angefreundet haben

Hallo allerseits! In diesem Artikel werden wir über die persönlichen Erfahrungen bei der Arbeit mit physischen Engines für einen Multiplayer-Shooter sprechen und uns hauptsächlich auf die Interaktion von Physik und ECS konzentrieren : Welche Art von Rechen wir während der Arbeit eingesetzt haben, was wir gelernt haben, warum wir uns für bestimmte Lösungen entschieden haben.



Lassen Sie uns zunächst herausfinden, warum ein physischer Motor benötigt wird. Es gibt keine allgemeingültige Antwort: In jedem Spiel erfüllt es seinen Zweck. Einige Spiele verwenden physische Engines, um das Verhalten von Objekten in der Welt korrekt zu simulieren und den Effekt des Eintauchens des Spielers zu erzielen. In anderen ist die Physik die Grundlage des Spiels - wie zum Beispiel Angry Birds und Red Faction. Es gibt auch „Sandkästen“, in denen sich die physikalischen Gesetze von den üblichen unterscheiden und so das Gameplay interessanter und ungewöhnlicher machen (Portal, A Slower of Light).

Aus programmtechnischer Sicht ermöglicht es die physische Engine, den Prozess der Simulation des Verhaltens von Objekten in einem Spiel zu vereinfachen. In der Tat ist es eine Bibliothek, die Beschreibungen der physikalischen Eigenschaften von Objekten speichert. In Gegenwart einer physischen Engine müssen wir kein System der Interaktion zwischen Körpern und universellen Gesetzen entwickeln, nach dem die Spielwelt leben wird. Dies spart eine Menge Zeit und Entwicklungsaufwand.

Bild
Das obige Diagramm beschreibt das Wesentliche des Players, seiner Komponenten und deren Daten sowie die Systeme, die mit dem Player und seinen Komponenten zusammenarbeiten. Das Schlüsselobjekt im Diagramm ist der Spieler: Er kann sich im Raum bewegen - Transformations- und Bewegungskomponenten, MoveSystem; hat eine gewisse Gesundheit und kann sterben - Komponente Gesundheit, Schaden, DamageSystem; Nach dem Tod erscheint am Respawn-Punkt die Transform-Komponente für die Position, das RespawnSystem. kann unverwundbar sein - Komponente unbesiegbar.

Was zeichnet die Implementierung der Spielphysik für Schützen aus?


Es gibt keine komplexen physischen Interaktionen in unserem Spiel, aber es gibt eine Reihe von Dingen, für die noch eine physische Engine benötigt wird. Ursprünglich wollten wir damit einen Charakter in der Welt nach vorher festgelegten Gesetzen bewegen. Dies geschieht normalerweise, indem dem Körper ein bestimmter Impuls oder eine konstante Geschwindigkeit verliehen wird. Anschließend werden mit der Simulate / Update-Methode der Bibliothek alle darin registrierten Körper genau einen Schritt vorwärts simuliert.

In Shootern wird 3D-Physik häufig nicht nur verwendet, um die Bewegungen des Charakters zu simulieren, sondern auch, um die Ballistik von Kugeln und Raketen, Sprüngen, Interaktionen zwischen den Charakteren und der Umgebung korrekt zu verarbeiten. Wenn der Schütze behauptet, realistisch zu sein und die wahren Empfindungen des Schießprozesses vermitteln zu wollen, braucht er nur einen physischen Motor. Wenn ein Spieler eine Schrotflinte auf ein Ziel schießt, erwartet er eine Erfahrung und ein Ergebnis, das so nahe wie möglich an dem liegt, das er bereits aus einem Langzeitschießspiel kennt - etwas radikal Neues wird ihn höchstwahrscheinlich unangenehm überraschen.

Bei unserem Spiel gibt es jedoch eine Reihe von Einschränkungen. Da unser Schütze mobil ist, impliziert er keine komplexen Interaktionen der Charaktere untereinander und mit der umgebenden Welt. Er erfordert keine schöne Ballistik, Zerstörbarkeit und kein Springen auf einer unebenen Oberfläche. Gleichzeitig und aus demselben Grund gibt es sehr strenge Verkehrsanforderungen. 3D-Physik wäre in diesem Fall überflüssig: Sie würde nur einen kleinen Teil ihrer Rechenressourcen verbrauchen und unnötige Daten erzeugen, was in einem Mobilfunknetz und einer ständigen Synchronisation des Clients mit dem Server über UDP zu viel Platz beanspruchen würde. An dieser Stelle sei daran erinnert, dass es in unserem Netzwerkmodell noch Vorhersagen und Abgleiche gibt , die auch Abrechnungen auf dem Kunden beinhalten. Als Ergebnis erhalten wir, dass unsere Physik so schnell wie möglich arbeiten sollte, um mobile Geräte erfolgreich zu starten und zu bearbeiten, ohne das Rendern und andere Client-Subsysteme zu beeinträchtigen.

3D-Physik passte also nicht zu uns. Aber auch wenn das Spiel dreidimensional aussieht, ist es nicht so, dass die Physik darin auch dreidimensional ist: Alles bestimmt die Art der Interaktion von Objekten miteinander. Oft werden Effekte, die von der 2D-Physik nicht abgedeckt werden können, entweder angepasst, dh es wird eine Logik geschrieben, die wie dreidimensionale Interaktionen aussieht, oder sie werden einfach durch visuelle Effekte ersetzt, die das Gameplay nicht beeinflussen. In "Heroes of the Storm", "Defense of the Ancients", "League of Legends" kann die zweidimensionale Physik alle Gameplay-Funktionen des Spiels bereitstellen, ohne die Bildqualität oder das von Spieldesignern und Künstlern der Welt erzeugte Glaubwürdigkeitsgefühl zu beeinträchtigen. So gibt es zum Beispiel in diesen Spielen springende Charaktere, aber es gibt keinen physikalischen Sinn für die Höhe ihres Sprungs, so dass es sich um eine zweidimensionale Simulation handelt und eine Art Flag wie _isInTheAir gesetzt wird, wenn sich der Charakter in der Luft befindet - dies wird bei der Berechnung der Logik berücksichtigt.

Daher wurde entschieden, 2D-Physik zu verwenden. Wir schreiben das Spiel in Unity, aber der Server verwendet .net ohne Unity, was Unity nicht versteht. Da der Löwenanteil des Simulationscodes zwischen dem Client und dem Server durchsucht wird, haben wir uns nach etwas Plattformübergreifendem umgesehen, nämlich nach einer physischen Bibliothek, die in reinem C # geschrieben ist, ohne systemeigenen Code, um die Gefahr eines Absturzes mobiler Plattformen auszuschließen. Unter Berücksichtigung der Besonderheiten der Arbeit der Schützen , insbesondere des ständigen Zurückspulens auf dem Server, um festzustellen, wo der Spieler geschossen hat, war es für uns außerdem wichtig, dass die Bibliothek mit der Geschichte arbeiten konnte, dh, wir konnten die Position der Körper N Bilder in der Zeit zurückverfolgen . Und natürlich sollte das Projekt nicht abgebrochen werden: Es ist wichtig, dass der Autor es unterstützt und Fehler, die während des Betriebs gefunden werden, schnell beheben kann.

Es stellte sich heraus, dass zu diesem Zeitpunkt nur sehr wenige Bibliotheken unsere Anforderungen erfüllen konnten. Tatsächlich war nur eines für uns geeignet - VolatilePhysics .

Die Bibliothek ist insofern bemerkenswert, als sie sowohl mit Unity-Lösungen als auch mit Lösungen ohne Unity-Lösungen zusammenarbeitet und es Ihnen auch ermöglicht, Rakecasts in den vorherigen Status von Objekten zu erstellen, z. Geeignet für Shooter-Logik. Der Vorteil der Bibliothek besteht außerdem darin, dass Sie mit dem Mechanismus zur Steuerung des Simulationsstarts von Simulate () die Bibliothek jederzeit erstellen können, wenn der Client sie benötigt. Und ein weiteres Merkmal - die Fähigkeit, zusätzliche Daten in den physischen Körper zu schreiben. Dies kann nützlich sein, wenn ein Objekt aus einer Simulation in den Ergebnissen von Reykast angesprochen wird. Dies verringert jedoch die Leistung erheblich.

Nachdem wir einige Tests durchgeführt und sichergestellt hatten, dass der Client und der Server ohne Abstürze gut mit VolatilePhysics interagieren, haben wir uns dafür entschieden.

Wie wir die Bibliothek auf die übliche Art und Weise betraten und wie es dazu kam


Der erste Schritt bei der Arbeit mit VolatilePhysics ist die Erstellung der physikalischen Welt von VoltWorld. Es ist eine Proxy-Klasse, mit der die Hauptarbeit ausgeführt wird: Optimieren, Simulieren von Daten über Objekte, Rakecasts usw. Wir haben sie in eine spezielle Fassade eingepackt, damit wir die Bibliotheksimplementierung in Zukunft auf etwas anderes umstellen können. Der Fassadencode sah folgendermaßen aus:

Code anzeigen
public sealed class PhysicsWorld { public const int HistoryLength = 32; private readonly VoltWorld _voltWorld; private readonly Dictionary<uint, VoltBody> _cache = new Dictionary<uint, VoltBody>(); public PhysicsWorld(float deltaTime) { _voltWorld = new VoltWorld(HistoryLength) { DeltaTime = deltaTime }; } public bool HasBody(uint tag) { return _cache.ContainsKey(tag); } public VoltBody GetBody(uint tag) { VoltBody body; _cache.TryGetValue(tag, out body); return body; } public VoltRayResult RayCast(Vector2 origin, Vector2 direction, float distance, VoltBodyFilter filter, int ticksBehind) { var ray = new VoltRayCast(origin, direction.normalized, distance); var result = new VoltRayResult(); _voltWorld.RayCast(ref ray, ref result, filter, ticksBehind); return result; } public VoltRayResult CircleCast(Vector2 origin, Vector2 direction, float distance, float radius, VoltBodyFilter filter, int ticksBehind) { var ray = new VoltRayCast(origin, direction.normalized, distance); var result = new VoltRayResult(); _voltWorld.CircleCast(ref ray, radius, ref result, filter, ticksBehind); return result; } public void Update() { _voltWorld.Update(); } public void Update(uint tag) { var body = _cache[tag]; _voltWorld.Update(body, true); } public void UpdateBody(uint tag, Vector2 position, float angle) { var body = _cache[tag]; body.Set(position, angle); } public void CreateStaticCircle(Vector2 origin, float radius, uint tag) { var shape = _voltWorld.CreateCircleWorldSpace(origin, radius, 1f, 0f, 0f); var body = _voltWorld.CreateStaticBody(origin, 0, shape); body.UserData = tag; } public void CreateDynamicCircle(Vector2 origin, float radius, uint tag) { var shape = _voltWorld.CreateCircleWorldSpace(origin, radius, 1f, 0f, 0f); var body = _voltWorld.CreateDynamicBody(origin, 0, shape); body.UserData = tag; body.CollisionFilter = StaticCollisionFilter; _cache.Add(tag, body); } public void CreateStaticSquare(Vector2 origin, float rotationAngle, Vector2 extents, uint tag) { var shape = _voltWorld.CreatePolygonBodySpace(extents.GetRectFromExtents(), 1, 0, 0); var body = _voltWorld.CreateStaticBody(origin, rotationAngle, shape); body.UserData = tag; } public void CreateDynamicSquare(Vector2 origin, float rotationAngle, Vector2 extents, uint tag) { var shape = _voltWorld.CreatePolygonBodySpace(extents.GetRectFromExtents(), 1, 0, 0); var body = _voltWorld.CreateDynamicBody(origin, rotationAngle, shape); body.UserData = tag; body.CollisionFilter = StaticCollisionFilter; _cache.Add(tag, body); } public IEnumerable<VoltBody> GetBodies() { return _voltWorld.Bodies; } private static bool StaticCollisionFilter(VoltBody a, VoltBody b) { return b.IsStatic; } } 


Bei der Erstellung der Welt wird die Größe der Geschichte angegeben - die Anzahl der Zustände der Welt, die in der Bibliothek gespeichert werden. In unserem Fall betrug die Anzahl 32: 30 Frames pro Sekunde. Wir benötigen sie basierend auf der Anforderung, die Logik zu aktualisieren, und 2 zusätzliche, falls wir die Grenzen des Debugging-Prozesses überschreiten. Der Code berücksichtigt auch nach außen geworfene Methoden, die physische Körper erzeugen, und verschiedene Arten von Reykast.

Wie wir uns aus früheren Artikeln erinnern, dreht sich die ECS-Welt im Wesentlichen um einen regelmäßigen Aufruf von Execute-Methoden für alle darin enthaltenen Systeme. An den richtigen Stellen jedes Systems verwenden wir Anrufe für unsere Fassade. Anfangs haben wir keine Chargen geschrieben, um die physische Maschine herauszufordern, obwohl es solche Gedanken gab. Innerhalb der Fassade findet ein Aufruf von Update () der physischen Welt statt, und die Bibliothek simuliert alle Interaktionen von Objekten, die pro Frame aufgetreten sind.

Die Arbeit mit der Physik besteht also aus zwei Komponenten: der gleichmäßigen Bewegung der Körper im Raum in einem Bild und dem vielen Rakast, der für das Schießen benötigt wird, der richtigen Wirkung von Effekten und vielen anderen Dingen. Reykasten sind besonders relevant in der Geschichte des Zustands physischer Körper.

Nach den Ergebnissen unserer Tests haben wir schnell festgestellt, dass die Bibliothek bei unterschiedlichen Geschwindigkeiten sehr schlecht funktioniert, und ab einer bestimmten Geschwindigkeit können Körper leicht Wände passieren. Mit der kontinuierlichen Kollisionserkennung sind keine Einstellungen verknüpft, um dieses Problem in unserem Motor zu lösen. Zu dieser Zeit gab es jedoch keine Alternativen zu unserer Lösung auf dem Markt. Daher musste ich eine eigene Version von sich bewegenden Objekten auf der ganzen Welt entwickeln und Physikdaten mit ECS synchronisieren. So lautet beispielsweise unser Code für das Bewegungssystem wie folgt:

Code anzeigen
 using System; ... using Volatile; public sealed class MovePhysicsSystem : ExecutableSystem { private readonly PhysicsWorld _physicsWorld; private readonly CollisionFilter _moveFilter; private readonly VoltBodyFilter _collisionFilterDelegate; public MovePhysicsSystem(PhysicsWorld physicsWorld) { _physicsWorld = physicsWorld; _moveFilter = new CollisionFilter(true, CollisionLayer.ExplosiveBarrel); _collisionFilterDelegate = _moveFilter.Filter; } public override void Execute(GameState gs) { _moveFilter.State = gs; foreach (var pair in gs.WorldState.Movement) { ExecuteMovement(gs, pair.Key, pair.Value); } _physicsWorld.Update(); foreach (var pair in gs.WorldState.PhysicsDynamicBody) { if(pair.Value.IsAlive) { ExecutePhysicsDynamicBody(gs, pair.Key); } } } public override void Execute(GameState gs, uint avatarId) { _moveFilter.State = gs; var movement = gs.WorldState.Movement[avatarId]; if (movement != null) { ExecuteMovement(gs, avatarId, movement); _physicsWorld.Update(avatarId); var physicsDynamicBody = gs.WorldState.PhysicsDynamicBody[avatarId]; if (physicsDynamicBody != null && physicsDynamicBody.IsAlive) ExecutePhysicsDynamicBody(gs, avatarId); } } private void ExecutePhysicsDynamicBody(GameState gs, uint entityId) { var body = _physicsWorld.GetBody(entityId); if (body != null) { var transform = gs.WorldState.Transform[entityId]; transform.Position = body.Position; } } private void ExecuteMovement(GameState gs, uint entityId, Movement movement) { var body = _physicsWorld.GetBody(entityId); if (body != null) { float raycastRadius; if (CalculateRadius(gs, entityId, out raycastRadius)) { return; } body.AngularVelocity = 0; body.LinearVelocity = movement.Velocity; var movPhysicInfo = gs.WorldState.MovementPhysicInfo[entityId]; var collisionDirection = CircleRayCastSpeedCorrection(body, GameState.TickDurationSec, raycastRadius); CheckMoveInWall(movement, movPhysicInfo, collisionDirection, gs.WorldState.Transform[entityId]); } } private static bool CalculateRadius(GameState gs, uint id, out float raycastRadius) { raycastRadius = 0; var circleShape = gs.WorldState.DynamicCircleCollider[id]; if (circleShape != null) { raycastRadius = circleShape.Radius; } else { var boxShape = gs.WorldState.DynamicBoxCollider[id]; if (boxShape != null) { raycastRadius = boxShape.RaycastRadius; } else { gs.Log.Error(string.Format("Physics body {0} doesn't contains shape!", id)); return true; } } return false; } private static void CheckMoveInWall(Movement movement, MovementPhysicInfo movPhysicInfo, Vector2 collisionDirection, Transform transform) { // 60 is the max angle when player move in wall and can shoot through the wall from weapon without target. const float maxAngleToWall = 60; if (movement.Velocity.IsEqual(Vector2.zero)) { if (movPhysicInfo.LastCollisionDirection.IsEqual(Vector2.zero)) { var angleToCollision = transform.Angle.GetDirection().CalculateAbsoluteAngleInDegrees(movPhysicInfo.LastCollisionDirection); movPhysicInfo.TurnOnWall = angleToCollision <= maxAngleToWall; } return; } movPhysicInfo.LastCollisionDirection = collisionDirection * -1f; if (movPhysicInfo.LastCollisionDirection.IsEqual(Vector2.zero)) { movPhysicInfo.TurnOnWall = false; movPhysicInfo.LastCollisionDirection = collisionDirection; } else { var angleToCollision = transform.Angle.GetDirection().CalculateAbsoluteAngleInDegrees(movPhysicInfo.LastCollisionDirection); movPhysicInfo.TurnOnWall = angleToCollision <= maxAngleToWall; } } // I can't believe we are using a physics engine and have to write such kludges private Vector2 CircleRayCastSpeedCorrection(VoltBody targetBody, float deltaSeconds, float rayCastRadius) { if (rayCastRadius <= 0) { return Vector2.zero; } var speed = targetBody.LinearVelocity; var position = targetBody.Position; var direction = speed * deltaSeconds; var rayCastResult = _physicsWorld.CircleCast(position + direction.normalized * 0.1f, direction, direction.magnitude, rayCastRadius, _collisionFilterDelegate, 0); if (rayCastResult.Body == null) { return Vector2.zero; } var magSpeed = speed.magnitude; if (rayCastResult.Distance > 0) { var penetratingDistance = magSpeed * deltaSeconds - rayCastResult.Distance; var sinVelocityEdge = Vector2.Dot(-speed.normalized, rayCastResult.Normal); var biasSpeed = penetratingDistance * sinVelocityEdge / deltaSeconds; var biasVector = rayCastResult.Normal * biasSpeed * 1.1f; var resultVelocity = speed + biasVector; if (magSpeed <= 0) { resultVelocity = Vector2.zero; } targetBody.LinearVelocity = resultVelocity; return rayCastResult.Normal; } var destination = rayCastResult.Body.Position; direction = destination - position; var rayCastResultToBody = _physicsWorld.RayCast(position, direction, direction.magnitude, _collisionFilterDelegate, 0); if (rayCastResultToBody.IsValid) targetBody.LinearVelocity = rayCastResultToBody.Normal * magSpeed * deltaSeconds; return rayCastResultToBody.Normal; } } 


Die Idee ist, dass wir vor jeder Bewegung eines Charakters einen CircleCast in seiner Bewegungsrichtung erstellen , um festzustellen, ob sich ein Hindernis davor befindet. CircleCast wird benötigt, da die Projektionen der Charaktere im Spiel einen Kreis darstellen und sie nicht in den Ecken zwischen verschiedenen Geometrien stecken bleiben sollen. Dann betrachten wir das Inkrement der Geschwindigkeit und weisen dem Objekt der physischen Welt diesen Wert als seine Geschwindigkeit in einem Frame zu. Der nächste Schritt ist der Aufruf der Simulationsmethode des Physical Engine Update (), die alle benötigten Objekte verschiebt und gleichzeitig den alten Zustand in der Historie aufzeichnet. Nachdem die Simulation innerhalb der Engine abgeschlossen ist, lesen wir diese simulierten Daten, kopieren sie in die Transform-Komponente unseres ECS und senden sie dann weiter, insbesondere über das Netzwerk.

Dieser Ansatz bei der Aktualisierung der Physik mit kleinen kontrollierten Datenblöcken über die Bewegungsgeschwindigkeit des Charakters erwies sich als sehr effektiv bei der Beseitigung physikalischer Diskrepanzen auf dem Client und dem Server. Und da unsere Physik nicht deterministisch ist - das heißt, mit denselben Eingabedaten kann das Simulationsergebnis variieren - wurde viel darüber diskutiert, ob es sich überhaupt lohnt, sie zu verwenden, und ob irgendjemand in der Branche etwas Ähnliches tut. mit einem deterministischen physischen Motor in der Hand. Glücklicherweise haben wir auf der Game Developers Conference einen hervorragenden Bericht der Entwickler von NetherRealm Studios über die Netzwerkkomponente ihrer Spiele gefunden und festgestellt, dass ein solcher Ansatz wirklich funktioniert. Nachdem wir das System vollständig zusammengebaut und in mehreren Tests getestet haben, haben wir ungefähr 50 falsche Vorhersagen für 9000 Zecken erhalten, d. H. Während des fünfminütigen Gefechts. Eine solche Anzahl von verpassten Vorhersagen kann durch den Abgleichsmechanismus und die visuelle Interpolation der Position des Spielers leicht ausgeglichen werden. Fehler, die bei häufigen manuellen Aktualisierungen der Physik unter Verwendung Ihrer eigenen Daten auftreten, sind unerheblich. Daher kann eine visuelle Interpolation relativ schnell erfolgen. Sie ist nur erforderlich, damit kein visueller Sprung im Zeichenmodell erfolgt.

Um zu überprüfen, ob der Client- und der Serverstatus übereinstimmen, haben wir eine selbstgeschriebene Klasse der folgenden Form verwendet:

Code anzeigen
 using PS.Logs.Unity; /// <summary> /// Compares the same avatar in two states. Compares the values potentially /// affected by prediction. /// </summary> public sealed class GameStateComparer : IGameStateComparer { public bool IsSame(GameState s1, GameState s2, uint avatarId) { if (s1 == null && s2 != null || s1 != null && s2 == null) { return false; } if (s1 == null && s2 == null) return false; var entity1 = s1.WorldState[avatarId]; var entity2 = s2.WorldState[avatarId]; if (entity1 == null && entity2 == null) { return false; } if (entity1 == null || entity2 == null) { LogManager.Debug("entity is different"); return false; } if (s1.Time != s2.Time) { LogManager.Warning(string.Format("Trying to compare states with different time! Predicted time: {0} Server time: {1}", s1.Time, s2.Time)); return false; } if (s1.WorldState.Transform[avatarId] != s2.WorldState.Transform[avatarId]) { LogManager.Debug("Transform is different"); return false; } // ... some code ... return true; } } 


Bei Bedarf kann es automatisiert werden, aber wir haben dies nicht getan, obwohl wir in Zukunft darüber nachgedacht haben.

Vergleichscode transformieren:

Code anzeigen
 public static bool operator ==(Transform a, Transform b) { if ((object)a == null && (object)b == null) { return true; } if ((object)a == null && (object)b != null) { return false; } if ((object)a != null && (object)b == null) { return false; } if (Math.Abs(a.Angle - b.Angle) > 0.01f) { return false; } if (Math.Abs(a.Position.x - b.Position.x) > 0.01f || Math.Abs(a.Position.y - b.Position.y) > 0.01f) { return false; } return true; } 



Erste Schwierigkeiten


Es gab keine Probleme mit der Bewegungssimulation, obwohl sie auf eine 2D-Ebene projiziert werden konnte - die Physik funktionierte in solchen Fällen sehr gut, aber irgendwann kamen Spieledesigner und sagten: „Wir wollen Granaten!“ Und wir dachten, dass sich nicht viel ändern würde , warum nicht den 3D-Flug des physischen Körpers mit nur 2D-Daten simulieren?

Und sie führten das Konzept der Höhe für einige Objekte ein.

Wie das Gesetz der Höhenänderung im Laufe der Zeit für einen verlassenen Körper aussieht, durchlaufen sie den Physikunterricht in der achten Klasse, so dass sich die Entscheidung über die Ballistik als trivial herausstellte. Aber die Lösung mit Kollisionen war nicht mehr so ​​trivial. Stellen wir uns diesen Fall vor: Eine Granate sollte während des Fluges entweder mit einer Mauer kollidieren oder darüber fliegen, je nach aktueller Höhe und Höhe der Mauer. Wir werden das Problem nur in der zweidimensionalen Welt lösen, in der die Granate durch einen Kreis und die Wand durch ein Rechteck dargestellt wird.


Ansicht der Geometrie von Objekten zur Lösung des Problems

Zunächst haben wir die Interaktion des dynamischen Körpers einer Granate mit anderen statischen und dynamischen Körpern ausgeschaltet. Dies ist notwendig, um sich auf das Ziel zu konzentrieren. In unserer Aufgabe sollte eine Granate in der Lage sein, andere Objekte zu durchdringen und über eine Wand zu fliegen, wenn sich ihre Projektionen auf einer zweidimensionalen Ebene schneiden. In einer normalen Interaktion können zwei Objekte nicht durcheinander kommen. Bei einer Granate mit einer benutzerdefinierten Bewegungslogik und -höhe haben wir dies unter bestimmten Bedingungen zugelassen.

Wir haben eine separate Komponente von GrenadeMovement für die Granate eingeführt, in der wir das Konzept der Höhe eingeführt haben:

 [Component] public class GrenadeMovement { public float Height; [DontPack] public Vector2 Velocity; [DontPack] public float VerticalVelocity; public GrenadeMovement(float height, Vector2 velocity, float verticalVelocity) { } } 

Jetzt hat die Granate eine Höhenkoordinate, aber diese Information gibt dem Rest der Welt nichts. Deshalb haben wir uns entschlossen zu schummeln und folgende Bedingung hinzugefügt: Eine Granate kann über Mauern fliegen, aber nur mit einer bestimmten Höhe. Die gesamte Definition von Kollisionen bestand also darin, Projektionskollisionen zu überprüfen und die Höhe der Wand mit dem Wert des GrenadeMovement.Height-Felds zu vergleichen. Wenn die Flughöhe der Granate geringer ist, stößt sie mit der Wand zusammen. Andernfalls kann sie sich ruhig weiter auf ihrem Weg bewegen, auch im 2D-Raum.

In der ersten Iteration fiel die Granate einfach, als wir Kreuzungen fanden, aber dann fügten wir elastische Kollisionen hinzu, und es begann, sich von dem Ergebnis, das wir in 3D erhalten würden, fast ununterscheidbar zu verhalten.

Der vollständige Code zur Berechnung der Flugbahn einer Granate und der elastischen Kollisionen ist unten angegeben:

Code anzeigen
 using System; // ... some code ... using Volatile; namespace Common.WorldState { public sealed class GrenadeMovementSystem : ExecutableSystem { private struct Projection { public float Min; public float Max; } private float _r; private readonly Vector2[] _vertices = new Vector2[4]; private readonly Vector2[] _verticesV = new Vector2[4]; private Vector2 _Vunit; private Vector2 _VTunit; private Projection _wallProj1; private Projection _wallProj2; private Projection _wallProj1V; private Projection _wallProj2V; private const float CollisionPrecision = 1e-3f; private static readonly float HalfSlope = Mathf.Cos(Mathf.PI / 4.0f); private readonly ContactPointList _contactPoints = new ContactPointList(3); public override void Execute(GameState gs) { var settings = gs.RuleBook.GrenadeConfig[1]; _r = settings.R; var floorDampeningPerTick = (float)Math.Pow(settings.FloorDampening, 1.0 / GameState.Hz); foreach (var grenade in gs.WorldState.GrenadeMovement) { // Gravity must take effect before collision // because contact with walls may and will adjust vertical velocity // and penetration will even move the ball up. grenade.Value.VerticalVelocity -= settings.Gravity * GameState.TickDurationSec; grenade.Value.Height += grenade.Value.VerticalVelocity * GameState.TickDurationSec; // prevent falling through floor if (grenade.Value.Height <= _r) { // slow down horizontal movement by floor friction // actually, friciton is simplified to just dampening coefficient var spdH = grenade.Value.Velocity.sqrMagnitude; var spdV = grenade.Value.VerticalVelocity; var cos = spdH / Mathf.Sqrt(spdH * spdH + spdV * spdV); grenade.Value.Velocity *= floorDampeningPerTick * cos; // slow down vertical movement grenade.Value.VerticalVelocity = settings.FloorRestitution * Math.Abs(grenade.Value.VerticalVelocity); // move up to the floor level grenade.Value.Height = _r; } // A collision will stop the ball and change its velocity. // Otherwise it will be moved by velocity PerformCollisionAndMovement(gs, grenade.Key, grenade.Value); } } private void PerformCollisionAndMovement(GameState gs, uint id, GrenadeMovement grenade) { var settings = gs.RuleBook.GrenadeConfig[1]; var velocity = grenade.Velocity * GameState.TickDurationSec; var trans = gs.WorldState.Transform[id]; var position = trans.Position; _Vunit = velocity.normalized; _VTunit = new Vector2(-_Vunit.y, _Vunit.x); _vertices[0] = position + _VTunit * _r; _vertices[1] = position - _VTunit * _r; _vertices[2] = _vertices[1] + velocity; _vertices[3] = _vertices[0] + velocity; _contactPoints.Reset(); int collisions = 0; var grenProj1V = ProjectCapsule(_Vunit, _vertices, position, velocity); var grenProj2V = ProjectCapsule(_VTunit, _vertices, position, velocity); collisions += CollideWithStaticBoxes(gs, id, position, velocity, grenade, grenProj1V, grenProj2V); collisions += CollideWithCircles(gs, gs.RuleBook.StaticCircleCollider, gs.RuleBook.Transform, id, position, velocity, grenade, grenProj1V, grenProj2V, (CollisionLayer)~0); collisions += CollideWithCircles(gs, gs.WorldState.DynamicCircleCollider, gs.WorldState.Transform, id, position, velocity, grenade, grenProj1V, grenProj2V, ~CollisionLayer.Character); if (collisions == 0) { trans.Position += velocity; } else { var contactSuperposition = CalculateContactSuperposition(); trans.Position += velocity * contactSuperposition.TravelDistance; var reflectedVelocity = grenade.Velocity - 2.0f * Vector2.Dot(grenade.Velocity, contactSuperposition.Normal) * contactSuperposition.Normal; reflectedVelocity *= settings.WallRestitution; #if DEBUG_GRENADES gs.Log.Debug("contact" + "\n\ttravel " + contactSuperposition.TravelDistance + "\n\tcontactNormal " + contactSuperposition.Normal.x + ":" + contactSuperposition.Normal.y + "\n\treflected V " + reflectedVelocity.x + ":" + reflectedVelocity.y); #endif grenade.Velocity = reflectedVelocity; } } private int CollideWithStaticBoxes( GameState gs, uint id, Vector2 position, Vector2 velocity, GrenadeMovement grenade, Projection grenProj1V, Projection grenProj2V) { var settings = gs.RuleBook.GrenadeConfig[1]; var collisions = 0; // TODO spatial query foreach (var collider in gs.RuleBook.StaticBoxCollider) { var wall = collider.Value; var transform = gs.RuleBook.Transform[collider.Key]; var colliderData = gs.RuleBook.PrecomputedColliderData[collider.Key]; // test projection to V _wallProj1V = ProjectPolygon(_Vunit, colliderData.Vertices); if (!Overlap(_wallProj1V, grenProj1V)) continue; // test projection to VT _wallProj2V = ProjectPolygon(_VTunit, colliderData.Vertices); if (!Overlap(_wallProj2V, grenProj2V)) continue; // test projection to wall axis 1 _wallProj1 = ProjectPolygon(colliderData.Axis1, colliderData.Vertices); var grenProj1 = ProjectCapsule(colliderData.Axis1, _vertices, position, velocity); if (!Overlap(_wallProj1, grenProj1)) continue; // test projection to wall axis 2 _wallProj2 = ProjectPolygon(colliderData.Axis2, colliderData.Vertices); var grenProj2 = ProjectCapsule(colliderData.Axis2, _vertices, position, velocity); if (!Overlap(_wallProj2, grenProj2)) continue; var lowWall = wall.Height < settings.TallWallHeight; if (lowWall) { // the wall is too far below, ignore it completely if (grenade.Height > wall.Height + _r) continue; // if grenade if falling down, it can bounce off the top of the wall if (grenade.VerticalVelocity < 0f) { if (grenade.Height > wall.Height - _r) { var localPV = WorldToBoxLocal(transform.Position, colliderData, position + velocity); #if DEBUG_GRENADES gs.Log.Debug("fall on wall" + "\n\tP+V " + (Px + Vx) + ":" + (Py + Vy) + "\n\tlocal " + localPV.x + ":" + localPV.y + "\n\tH w " + wall.Height + " g " + grenade.Height ); #endif if (Math.Abs(localPV.x) < wall.Size.x * 0.5f || Math.Abs(localPV.y) < wall.Size.y * 0.5f) { grenade.Height = wall.Height + _r; grenade.VerticalVelocity = settings.WallRestitution * Math.Abs(grenade.VerticalVelocity); continue; } } } } // collision detected // try to find minimal V before collision var scaleV = CalcTranslationScaleBeforeCollision(CheckBoxCollision, colliderData, 0, position, velocity); var contactPoint = CalcBoxContactPoint(transform.Position, wall, colliderData, position); #if DEBUG_GRENADES gs.Log.Debug("collision grenade #" + id + " with static box #" + collider.Key + "\n\tP=" + Px + ":" + Py + "\n\tV=" + Vx + ":" + Vy + " scale=" + scaleV + "\n\tP+Vs=" + (Px + Vx * scaleV) + ":" + (Py + Vy * scaleV) + "\n\twall pos " + transform.Position.x + ":" + transform.Position.y + " sz " + wall.Size.x + ":" + wall.Size.y + " angle " + transform.Angle + "\n\tproj V w " + _wallProj1V.Min + ":" + _wallProj1V.Max + " g " + grenProj1V.Min + ":" + grenProj1V.Max + " overlap=" + Overlap(_wallProj1V, grenProj1V) + "\n\tproj VT w " + _wallProj2V.Min + ":" + _wallProj2V.Max + " g " + grenProj2V.Min + ":" + grenProj2V.Max + " overlap=" + Overlap(_wallProj2V, grenProj2V) + "\n\taxis1 " + colliderData.Axis1.x + ":" + colliderData.Axis1.y + "\n\tproj 1 w " + _wallProj1.Min + ":" + _wallProj1.Max + " g " + grenProj1.Min + ":" + grenProj1.Max + " overlap=" + Overlap(_wallProj1, grenProj1) + "\n\taxis2 " + colliderData.Axis2.x + ":" + colliderData.Axis2.y + "\n\tproj 2 w " + _wallProj2.Min + ":" + _wallProj2.Max + " g " + grenProj2.Min + ":" + grenProj2.Max + " overlap=" + Overlap(_wallProj2, grenProj2) + "\n\tpoint " + contactPoint.Point.x + ":" + contactPoint.Point.y + " dotV " + Vector2.Dot(P - contactPoint.Point, V) ); #endif // ignore colliders that are behind if (Vector2.Dot(position - contactPoint.Point, velocity) >= 0.0f) continue; contactPoint.TravelDistance = velocity.magnitude * scaleV; _contactPoints.Add(ref contactPoint); collisions++; } return collisions; } private bool CheckBoxCollision(PrecomputedColliderData colliderData, int x, Vector2 position, Vector2 velocity) { _verticesV[0] = _vertices[0]; _verticesV[1] = _vertices[1]; _verticesV[2] = _vertices[1] + velocity; _verticesV[3] = _vertices[0] + velocity; // test projection to V var grenProj1V = ProjectCapsule(_Vunit, _verticesV, position, velocity); if (!Overlap(_wallProj1V, grenProj1V)) return false; // testing projection to VT would be redundant // test projection to wall axis 1 var grenProj1 = ProjectCapsule(colliderData.Axis1, _verticesV, position, velocity); if (!Overlap(_wallProj1, grenProj1)) return false; // test projection to wall axis 2 var grenProj2 = ProjectCapsule(colliderData.Axis2, _verticesV, position, velocity); if (!Overlap(_wallProj2, grenProj2)) return false; return true; } private int CollideWithCircles( GameState gs, Table<CircleCollider> colliderTable, Table<Transform> transformTable, uint id, Vector2 position, Vector2 velocity, GrenadeMovement grenade, Projection grenProj1V, Projection grenProj2V, CollisionLayer collisionLayers) { var settings = gs.RuleBook.GrenadeConfig[1]; var collisions = 0; foreach (var collider in colliderTable) { if ((int)collisionLayers != ~0) { var body = gs.WorldState.PhysicsDynamicBody[collider.Key]; if (body != null && (body.CollisionLayer & collisionLayers) == 0) continue; } var wall = collider.Value; var transform = transformTable[collider.Key]; // test projection to V _wallProj1V = ProjectCircle(_Vunit, transform.Position, wall.Radius); if (!Overlap(_wallProj1V, grenProj1V)) continue; // test projection to VT _wallProj2V = ProjectCircle(_VTunit, transform.Position, wall.Radius); if (!Overlap(_wallProj2V, grenProj2V)) continue; // test distance from the circle wall to semicircles on capsule ends var collisionDistance = (_r + wall.Radius) * (_r + wall.Radius); if ((position - transform.Position).sqrMagnitude > collisionDistance) continue; var distSqr = (position + velocity - transform.Position).sqrMagnitude; if (distSqr > collisionDistance) continue; var lowWall = wall.Height < settings.TallWallHeight; if (lowWall) { // the wall is too far below, ignore it completely if (grenade.Height > wall.Height + _r) continue; // if grenade if falling down, it can bounce off the top of the wall if (grenade.VerticalVelocity < 0f) { if (grenade.Height > wall.Height - _r) { #if DEBUG_GRENADES gs.Log.Debug("grenade #" + id + " falls on wall" + "\n\tP+V " + (Px + Vx) + ":" + (Py + Vy) + "\n\tdist " + Mathf.Sqrt(distSqr) + "\n\tH w " + wall.Height + " g " + grenade.Height ); #endif if (distSqr < wall.Radius * wall.Radius) { grenade.Height = wall.Height + _r; grenade.VerticalVelocity = settings.WallRestitution * Math.Abs(grenade.VerticalVelocity); continue; } } } } // collision detected // try to find minimal V before collision var scaleV = CalcTranslationScaleBeforeCollision(CheckCircleCollision, transform.Position, wall, position, velocity); var contactPoint = CalcCircleContactPoint(transform.Position, wall, position); #if DEBUG_GRENADES gs.Log.Debug("collision grenade #" + id + " with circle #" + collider.Key + "\n\tP=" + Px + ":" + Py + "\n\tV=" + Vx + ":" + Vy + " scale=" + scaleV + "\n\tP+Vs=" + (Px + Vx * scaleV) + ":" + (Py + Vy * scaleV) + "\n\tcircle pos " + transform.Position.x + ":" + transform.Position.y + " r " + wall.Radius + "\n\tdist " + (transform.Position - (P + V * scaleV)).magnitude + "\n\tproj V w " + _wallProj1V.Min + ":" + _wallProj1V.Max + " g " + grenProj1V.Min + ":" + grenProj1V.Max + " overlap=" + Overlap(_wallProj1V, grenProj1V) + "\n\tproj VT w " + _wallProj2V.Min + ":" + _wallProj2V.Max + " g " + grenProj2V.Min + ":" + grenProj2V.Max + " overlap=" + Overlap(_wallProj2V, grenProj2V) + "\n\tpoint " + contactPoint.Point.x + ":" + contactPoint.Point.y + " dotV " + Vector2.Dot(P - contactPoint.Point, V) ); #endif // ignore colliders that are behind if (Vector2.Dot(position - contactPoint.Point, velocity) >= 0.0f) continue; contactPoint.TravelDistance = velocity.magnitude * scaleV; _contactPoints.Add(ref contactPoint); collisions++; } return collisions; } private bool CheckCircleCollision(Vector2 wallCentre, CircleCollider wall, Vector2 position, Vector2 velocity) { _verticesV[0] = _vertices[0]; _verticesV[1] = _vertices[1]; _verticesV[2] = _vertices[1] + velocity; _verticesV[3] = _vertices[0] + velocity; // test projection to V var grenProj1V = ProjectCapsule(_Vunit, _verticesV, position, velocity); if (!Overlap(_wallProj1V, grenProj1V)) return false; // testing projection to VT would be redundant // test distance from the circle wall to the semicircle on the second capsule end var dSqr = (_r + wall.Radius) * (_r + wall.Radius); return (position + velocity - wallCentre).sqrMagnitude < dSqr; } private static float CalcTranslationScaleBeforeCollision<TData1, TData2>( Func<TData1, TData2, Vector2, Vector2, bool> collision, TData1 colliderData1, TData2 colliderData2, Vector2 position, Vector2 vector) { var min = 0.0f; var max = 1.0f; while (true) { var d = (max - min) * 0.5f; if (d < CollisionPrecision) break; var scale = min + d; if (collision(colliderData1, colliderData2, position, vector * scale)) { max = scale; } else { min = scale; } } return min; } private ContactPoint CalculateContactSuperposition() { ContactPoint contactSuperposition; _contactPoints.TryPopClosest(1000f, out contactSuperposition); ContactPoint contact; while (_contactPoints.TryPopClosest(contactSuperposition.TravelDistance, out contact)) { contactSuperposition.Normal += contact.Normal; } contactSuperposition.Normal = contactSuperposition.Normal.normalized; return contactSuperposition; } private static Projection ProjectPolygon(Vector2 axisNormalised, Vector2[] vertices) { Projection proj; var d = Vector2.Dot(axisNormalised, vertices[0]); proj.Min = d; proj.Max = d; for (var i = 1; i < vertices.Length; i++) { d = Vector2.Dot(axisNormalised, vertices[i]); proj.Min = Mathf.Min(proj.Min, d); proj.Max = Mathf.Max(proj.Max, d); } return proj; } private Projection ProjectCapsule(Vector2 axisNormalised, Vector2[] vertices, Vector2 p, Vector2 v) { var proj = ProjectPolygon(axisNormalised, vertices); proj = AddCircleProjection(proj, axisNormalised, p, _r); proj = AddCircleProjection(proj, axisNormalised, p + v, _r); return proj; } private static Projection AddCircleProjection(Projection proj, Vector2 axisNormalised, Vector2 centre, float r) { var c = Vector2.Dot(axisNormalised, centre); proj.Min = Mathf.Min(proj.Min, c - r); proj.Max = Mathf.Max(proj.Max, c + r); return proj; } private static Projection ProjectCircle(Vector2 axisNormalised, Vector2 centre, float r) { Projection proj; var c = Vector2.Dot(axisNormalised, centre); proj.Min = c - r; proj.Max = c + r; return proj; } private static bool Overlap(Projection p1, Projection p2) { return p1.Min < p2.Min ? p1.Max > p2.Min : p2.Max > p1.Min; } private static Vector2 WorldToBoxLocal(Vector2 wallCentre, PrecomputedColliderData colliderData, Vector2 position) { return new Vector2( Vector2.Dot(colliderData.Axis1, position) - Vector2.Dot(colliderData.Axis1, wallCentre), Vector2.Dot(colliderData.Axis2, position) - Vector2.Dot(colliderData.Axis2, wallCentre) ); } private static ContactPoint CalcBoxContactPoint(Vector2 wallCentre, BoxCollider wall, PrecomputedColliderData colliderData, Vector2 position) { var contactPoint = CaclBoxLocalContactPoint(wall.Size * 0.5f, WorldToBoxLocal(wallCentre, colliderData, position)); var worldAxisX = new Vector2(colliderData.Axis1.x, -colliderData.Axis1.y); var worldAxisY = new Vector2(colliderData.Axis1.y, colliderData.Axis1.x); contactPoint.Point = wallCentre + new Vector2(Vector2.Dot(worldAxisX, contactPoint.Point), Vector2.Dot(worldAxisY, contactPoint.Point)); contactPoint.Normal = new Vector2(Vector2.Dot(worldAxisX, contactPoint.Normal), Vector2.Dot(worldAxisY, contactPoint.Normal)); return contactPoint; } private static ContactPoint CaclBoxLocalContactPoint(Vector2 boxHalfSize, Vector2 localPosition) { ContactPoint localContactPoint = default(ContactPoint); // cases are numbered like numpad keys // 1, 2, 3 if (localPosition.y < -boxHalfSize.y) { // 1 if (localPosition.x < -boxHalfSize.x) { localContactPoint.Point = new Vector2(-boxHalfSize.x, -boxHalfSize.y); localContactPoint.Normal = new Vector2(-HalfSlope, -HalfSlope); } // 2, 3 else { // 3 if (localPosition.x > boxHalfSize.x) { localContactPoint.Point = new Vector2(boxHalfSize.x, -boxHalfSize.y); localContactPoint.Normal = new Vector2(HalfSlope, -HalfSlope); } // 2 else { localContactPoint.Point = new Vector2(localPosition.x, -boxHalfSize.y); localContactPoint.Normal = new Vector2(0.0f, -1.0f); } } } // 4, 6, 7, 8, 9 else { // 7, 8, 9 if (localPosition.y > boxHalfSize.y) { // 7 if (localPosition.x < -boxHalfSize.x) { localContactPoint.Point = new Vector2(-boxHalfSize.x, boxHalfSize.y); localContactPoint.Normal = new Vector2(-HalfSlope, HalfSlope); } // 8, 9 else { // 9 if (localPosition.x > boxHalfSize.x) { localContactPoint.Point = new Vector2(boxHalfSize.x, boxHalfSize.y); localContactPoint.Normal = new Vector2(HalfSlope, HalfSlope); } // 8 else { localContactPoint.Point = new Vector2(localPosition.x, boxHalfSize.y); localContactPoint.Normal = new Vector2(0.0f, 1.0f); } } } // 4, 6 else { // 4 if (localPosition.x < -boxHalfSize.x) { localContactPoint.Point = new Vector2(-boxHalfSize.x, localPosition.y); localContactPoint.Normal = new Vector2(-1.0f, 0.0f); } // 6 else { localContactPoint.Point = new Vector2(boxHalfSize.x, localPosition.y); localContactPoint.Normal = new Vector2(1.0f, 0.0f); } } } return localContactPoint; } private static ContactPoint CalcCircleContactPoint(Vector2 wallCentre, CircleCollider wall, Vector2 position) { ContactPoint contactPoint = default(ContactPoint); contactPoint.Normal = (position - wallCentre).normalized; contactPoint.Point = wallCentre + wall.Radius * contactPoint.Normal; return contactPoint; } } } 


Sie haben Physik geschrieben. Was weiter?


Das Spiel lebt, entwickelt sich und im Laufe der Zeit wurde es für uns notwendig, irgendwie zu debuggen, was mit seiner physischen Engine auf dem Server passiert. In einem der Artikel habe ich bereits das Debuggen von ECS auf dem Server ausführlich beschrieben . Für die Physik haben wir einen direkten visuellen Editor, der Daten aus JSON entnimmt, deren Struktur zusammen mit dem restlichen ECS-Layout generiert wird. Dieser Editor sieht folgendermaßen aus:



, «». ECS, , . ― ― , , ECS, ECS . , API, , , . , .

- 2D-: , . , : , opensource , - . ECS, , . , , . - , , . ― - .

- , 3D-, , .

, , , . , , ECS .

Nützliche Links


:


:

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


All Articles