Bonjour à tous! Dans cet article, nous parlerons de l'expérience personnelle de travail avec des moteurs physiques pour un jeu de tir multijoueur et nous nous concentrerons principalement sur l'interaction de la physique et de l'
ECS : quel type de râteau nous sommes intervenus pendant le travail, ce que nous avons appris, pourquoi nous nous sommes installés sur des solutions spécifiques.

Voyons d'abord pourquoi un moteur physique est nécessaire. Il n'y a pas de réponse universelle: dans chaque jeu, il remplit sa fonction. Certains jeux utilisent des moteurs physiques pour simuler correctement le comportement des objets dans le monde afin d'obtenir l'effet d'immersion du joueur. Dans d'autres, la physique est la base du gameplay - comme, par exemple, Angry Birds et Red Faction. Il existe également des «bacs à sable» dans lesquels les lois physiques diffèrent des lois habituelles et rendent ainsi le gameplay plus intéressant et inhabituel (Portal, A Slower Speed of Light).
Du point de vue de la programmation, le moteur physique permet de simuler facilement le comportement des objets dans un jeu. En fait, c'est une bibliothèque qui stocke des descriptions des propriétés physiques des objets. En présence d'un moteur physique, nous n'avons pas besoin de développer un système d'interaction entre les corps et les lois universelles par lesquelles le monde du jeu va vivre. Cela économise une tonne de temps et d'efforts de développement.
Le diagramme ci - dessus décrit l'essence du lecteur, ses composants et leurs données, ainsi que les systèmes qui fonctionnent avec le lecteur et ses composants. L'objet clé du diagramme est le joueur: il peut se déplacer dans l'espace - composants Transform et Movement, MoveSystem; a une certaine santé et peut mourir - composant Health, Damage, DamageSystem; après la mort apparaît au point de réapparition - le composant Transformer pour la position, le RespawnSystem; peut être invulnérable - composant Invincible.Quelles sont les caractéristiques de l'implémentation de la physique des jeux pour les tireurs?
Il n'y a pas d'interactions physiques complexes dans notre jeu, mais il y a un certain nombre de choses pour lesquelles un moteur physique est toujours nécessaire. Initialement, nous avions prévu de l'utiliser pour déplacer un personnage dans le monde conformément à des lois prédéterminées. Habituellement, cela se fait en donnant au corps une certaine impulsion ou vitesse constante, après quoi, en utilisant la méthode Simuler / Mettre à jour de la bibliothèque, tous les corps qui y sont enregistrés sont simulés exactement un pas en avant.
Dans les tireurs, la physique 3D est souvent utilisée non seulement pour simuler les mouvements des personnages, mais aussi pour le traitement correct de la balistique des balles et des fusées, des sauts, de l'interaction des personnages entre eux et de l'environnement. Si le tireur prétend être réaliste et cherche à transmettre les vraies sensations du processus de tir, il a juste besoin d'un moteur physique. Lorsqu'un joueur tire un fusil de chasse sur une cible, il s'attend à obtenir de l'expérience et un résultat aussi proche que possible de celui qu'il connaît déjà d'un jeu de tir à long terme - quelque chose de radicalement nouveau le surprendra très probablement désagréablement.
Mais dans le cas de notre jeu, il y a un certain nombre de limitations. Étant donné que notre tireur est mobile, il n'implique pas d'interactions complexes entre les personnages les uns avec les autres et avec le monde environnant, il ne nécessite pas une belle balistique, une destructibilité, un saut sur une surface inégale. Mais en même temps et pour la même raison, il y a des exigences de trafic très strictes. La physique 3D dans ce cas serait redondante: elle n'utiliserait qu'une petite partie de ses ressources informatiques et générerait des données inutiles qui, dans un réseau mobile et une synchronisation constante du client avec le serveur via UDP, prendraient trop de place. Ici, il convient de rappeler que dans notre modèle de réseau, il existe encore des choses telles que la
prédiction et la réconciliation , qui impliquent également des règlements sur le client. En conséquence, nous obtenons que notre physique devrait fonctionner aussi rapidement que possible afin de réussir le lancement et le travail sur les appareils mobiles, sans interférer avec le rendu et les autres sous-systèmes clients.
La physique 3D ne nous convenait donc pas. Mais ici, il convient de rappeler que même si le jeu ressemble à trois dimensions, ce n'est pas un fait que la physique y est également en trois dimensions: tout détermine la nature de l'interaction des objets les uns avec les autres. Souvent, les effets qui ne peuvent pas être couverts par la physique 2D sont soit personnalisés - c'est-à-dire qu'une logique est écrite qui ressemble à des interactions en trois dimensions - soit simplement remplacés par des effets visuels qui n'affectent pas le gameplay. Dans Heroes of the Storm, Defense of the Ancients, League of Legends, la physique bidimensionnelle est en mesure de fournir toutes les fonctionnalités de jeu du jeu sans affecter la qualité de l'image ou le sentiment de crédibilité créé par les concepteurs de jeux et les artistes du monde. Ainsi, par exemple, dans ces jeux, il y a des personnages qui sautent, mais il n'y a pas de sens physique dans la hauteur de leur saut, donc cela revient à une simulation bidimensionnelle et à la mise en place d'une sorte de drapeau comme _isInTheAir lorsque le personnage est dans l'air - il est pris en compte lors du calcul de la logique.
Il a donc été décidé d'utiliser la physique 2D. Nous écrivons le jeu dans Unity, mais le serveur utilise .net sans Unity, ce que Unity ne comprend pas. Étant donné que la part du lion du code de simulation est fouillée entre le client et le serveur, nous avons commencé à rechercher quelque chose de multiplateforme - à savoir, une bibliothèque physique écrite en C # pur sans utiliser de code natif pour éliminer le danger de planter les plates-formes mobiles. De plus, compte tenu des
spécificités du travail des tireurs , en particulier des rembobinages constants sur le serveur afin de déterminer où le joueur a tiré, il était important pour nous que la bibliothèque puisse travailler avec l'histoire - c'est-à-dire que nous pouvions à moindre coût voir la position des corps N images dans le temps . Et, bien sûr, le projet ne doit pas être abandonné: il est important que l'auteur le prenne en charge et puisse rapidement corriger les bugs, le cas échéant, en cours de fonctionnement.
Il s'est avéré qu'à l'époque, très peu de bibliothèques pouvaient répondre à nos besoins. En fait, un seul nous convenait -
VolatilePhysics .
La bibliothèque est remarquable en ce qu'elle fonctionne avec les solutions Unity et Unity-less, et vous permet également de faire des rakecasts dans l'état passé des objets hors de la boîte, c'est-à-dire Convient pour la logique de tir. De plus, la commodité de la bibliothèque réside dans le fait que le mécanisme de contrôle du démarrage de la simulation de Simulate () vous permet de la produire à tout moment lorsque le client en a besoin. Et une autre caractéristique - la possibilité d'écrire des données supplémentaires sur le corps physique. Cela peut être utile lors de l'adressage d'un objet à partir d'une simulation dans les résultats de reykast - cependant, cela réduit considérablement les performances.
Après avoir fait quelques tests et nous être assurés que le client et le serveur interagissent bien avec VolatilePhysics sans plantages, nous avons opté pour cela.
Comment nous sommes entrés dans la bibliothèque de la manière habituelle de travailler avec ECS et ce qui en est sorti
La première étape lorsque vous travaillez avec VolatilePhysics est de créer le monde physique de VoltWorld. C'est une classe proxy, avec laquelle le travail principal a lieu: réglage, simulation de données sur les objets, reykast, etc. Nous l'avons enveloppé dans une façade spéciale afin qu'à l'avenir nous puissions changer l'implémentation de la bibliothèque pour autre chose. Le code de façade ressemblait à ceci:
Afficher le codepublic 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; } }
Lors de la création du monde, l'ampleur de l'histoire est indiquée - le nombre d'états du monde que la bibliothèque stockera. Dans notre cas, leur nombre était de 32: 30 images par seconde, nous en aurons besoin en fonction de l'exigence de mettre à jour la logique et de 2 supplémentaires au cas où nous dépasserions les limites du processus de débogage. Le code prend également en compte les méthodes de conversion vers l'extérieur qui génèrent des corps physiques et divers types de reykast.
Comme nous le rappelons des
articles précédents , le monde ECS s'articule essentiellement autour d'un appel régulier aux méthodes d'exécution pour tous les systèmes qui y sont inclus. Aux bons endroits de chaque système, nous utilisons les appels à notre façade. Initialement, nous n'avons pas écrit de batch pour contester le moteur physique, bien qu'il y ait eu de telles pensées. À l'intérieur de la façade, un appel à Update () du monde physique a lieu, et la bibliothèque simule toutes les interactions d'objets qui se sont produites par image.
Ainsi, le travail avec la physique se résume à deux composantes: le mouvement uniforme des corps dans l'espace dans une image et les nombreux rakast nécessaires pour la prise de vue, le bon fonctionnement des effets et bien d'autres choses. Les Reykasts sont particulièrement pertinents dans l'histoire de l'état des corps physiques.
D'après les résultats de nos tests, nous avons rapidement réalisé que la bibliothèque fonctionne très mal à différentes vitesses, et à une certaine vitesse, les corps commencent facilement à passer à travers les murs. Aucun paramètre n'est associé à la détection de collision continue pour résoudre ce problème dans notre moteur. Mais il n'y avait pas d'alternative à notre solution sur le marché à ce moment-là, j'ai donc dû trouver ma propre version des objets en mouvement dans le monde et synchroniser les données physiques avec ECS. Ainsi, par exemple, notre code pour le système de mouvement est le suivant:
Afficher le code 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) {
L'idée est qu'avant chaque déplacement de personnage, nous
réalisons un
CircleCast dans le sens de son mouvement afin de déterminer s'il y a un obstacle devant lui. CircleCast est nécessaire car les projections des personnages du jeu représentent un cercle, et nous ne voulons pas qu'ils se coincent dans les coins entre différentes géométries. Ensuite, nous considérons l'incrément de vitesse et attribuons cette valeur à l'objet du monde physique comme sa vitesse dans une image. L'étape suivante consiste à appeler la méthode de simulation du moteur physique Update (), qui déplace tous les objets dont nous avons besoin, enregistrant simultanément l'ancien état dans l'histoire. Une fois la simulation à l'intérieur du moteur terminée, nous lisons ces données simulées, les copions dans le composant Transform de notre ECS, puis continuons à travailler avec, en particulier, nous les envoyons sur le réseau.
Cette approche de mise à jour de la physique avec de petits morceaux contrôlés de données sur la vitesse de déplacement du personnage s’est avérée très efficace pour traiter les écarts de physique sur le client et le serveur. Et comme notre physique n'est pas déterministe - c'est-à-dire qu'avec les mêmes données d'entrée, le résultat de la simulation peut varier - il y a eu de nombreuses discussions pour savoir si cela vaut la peine de l'utiliser du tout et si quelqu'un dans l'industrie fait quelque chose de similaire, ayant un moteur physique déterministe en main. Heureusement, nous avons trouvé un excellent rapport des développeurs de NetherRealm Studios lors de la Game Developers Conference sur la composante réseau de leurs jeux et nous avons réalisé qu'une telle approche avait vraiment lieu. Après avoir entièrement assemblé le système et l'avoir exécuté sur plusieurs tests, nous avons obtenu environ 50 fausses prédictions pour 9000 ticks, c'est-à-dire pendant la bataille de cinq minutes. Un tel nombre de prédictions manquées est facilement nivelé par le mécanisme de réconciliation et l'interpolation visuelle de la position du joueur. Les erreurs qui se produisent lors des mises à jour manuelles fréquentes de la physique en utilisant vos propres données sont insignifiantes, par conséquent, l'interpolation visuelle peut avoir lieu assez rapidement - elle est nécessaire uniquement pour éviter un saut visuel dans le modèle de personnage.
Pour vérifier la coïncidence des états client et serveur, nous avons utilisé une classe auto-écrite de la forme suivante:
Si nécessaire, il peut être automatisé, mais nous ne l'avons pas fait, bien que nous y pensions à l'avenir.
Transformez le code de comparaison:
Afficher le code 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; }
Premières difficultés
Il n'y avait aucun problème avec la simulation du mouvement, alors qu'il pouvait être projeté sur un plan 2D - la physique dans de tels cas fonctionnait très bien, mais à un moment donné les concepteurs de jeux sont venus et ont dit: "Nous voulons des grenades!" Et nous avons pensé que rien ne changerait beaucoup , pourquoi ne pas simuler le vol 3D du corps physique avec seulement des données 2D à portée de main.
Et ils ont introduit le concept de hauteur pour certains objets.
À quoi ressemble la loi de l'altitude au fil du temps pour un corps abandonné, ils passent par des cours de physique en huitième année, donc la décision sur la balistique s'est avérée triviale. Mais la solution aux collisions n'était plus si banale. Imaginons ce cas: une grenade en vol devrait soit entrer en collision avec un mur, soit la survoler en fonction de sa hauteur actuelle et de la hauteur du mur. Nous ne résoudrons le problème que dans le monde à deux dimensions, où la grenade est représentée par un cercle et le mur par un rectangle.
Vue de la géométrie des objets pour résoudre le problème.Tout d'abord, nous avons désactivé l'interaction du corps dynamique d'une grenade avec d'autres corps statiques et dynamiques. Cela est nécessaire pour se concentrer sur l'objectif. Dans notre tâche, une grenade doit pouvoir traverser d'autres objets et «survoler» un mur lorsque leurs projections sur un plan bidimensionnel se croisent. Dans une interaction normale, deux objets ne peuvent pas se traverser, et pourtant dans le cas d'une grenade avec une logique et une hauteur de mouvement personnalisées, nous lui avons permis de le faire sous certaines conditions.
Nous avons introduit un composant distinct de GrenadeMovement pour la grenade, dans lequel nous avons introduit le concept de hauteur:
[Component] public class GrenadeMovement { public float Height; [DontPack] public Vector2 Velocity; [DontPack] public float VerticalVelocity; public GrenadeMovement(float height, Vector2 velocity, float verticalVelocity) { } }
Maintenant, la grenade a une coordonnée de hauteur, mais cette information ne donne rien au reste du monde. Par conséquent, nous avons décidé de tricher et avons ajouté la condition suivante: une grenade peut voler au-dessus des murs, mais seulement d'une certaine hauteur. Ainsi, toute la définition des collisions se résumait à vérifier les collisions de projection et à comparer la hauteur du mur avec la valeur du champ GrenadeMovement.Height. Si la hauteur de vol de la grenade est inférieure, elle entre en collision avec le mur, sinon elle peut calmement continuer à se déplacer le long de son chemin, y compris dans l'espace 2D.
Dans la première itération, la grenade est simplement tombée lors de la recherche d'intersections, mais nous avons ensuite ajouté des collisions élastiques, et elle a commencé à se comporter presque de façon indiscernable du résultat que nous obtiendrions en 3D.
Le code complet pour calculer la trajectoire d'une grenade et les collisions élastiques est donné ci-dessous:
. Et ensuite?
, , - , .
ECS . , , JSON, ECS. :

, «». ECS, , . ― ― , , ECS, ECS . , API, , , . , .
- 2D-: , . , : , opensource , - . ECS, , . , , . - , , . ― - .
- , 3D-, , .
, , , . , , ECS .
Liens utiles
:
: