Physique pour un tireur PvP mobile, ou comment nous avons refait un jeu à deux dimensions en un jeu à trois dimensions



Dans un article précédent, mon collègue a expliqué comment nous avons utilisé un moteur physique bidimensionnel dans notre jeu de tir multijoueur mobile. Et maintenant, je veux partager comment nous avons jeté tout ce que nous faisions avant et commencé à partir de zéro - en d'autres termes, comment nous avons transféré notre jeu du monde 2D à la 3D.

Tout a commencé avec le fait qu'une fois qu'un producteur et un concepteur de jeu de premier plan sont venus dans notre département de programmeurs et nous ont lancé un défi: un jeu de tir PvP Top-Down mobile avec des tirs dans des espaces confinés a dû être converti en un jeu de tir à la troisième personne avec des prises de vue dans des zones ouvertes. Dans ce cas, il est souhaitable que la carte ne ressemble pas à ceci:



Et donc:



Les exigences techniques étaient les suivantes:

  • taille de la carte - 100 × 100 mètres;
  • dénivelé - 40 mètres;
  • support pour tunnels, ponts;
  • tirer sur des cibles à différentes hauteurs;
  • les collisions avec une géométrie statique (nous n'avons pas de collisions avec d'autres personnages dans le jeu);
  • physique de la chute libre;
  • physique de lancer de grenade.

Pour l'avenir, je peux dire que notre jeu ne ressemblait pas à la dernière capture d'écran: il s'est avéré être un croisement entre la première et la deuxième options.

Première option: structure en couches


La première idée a été proposée non pas de changer le moteur physique, mais simplement d'ajouter plusieurs couches de niveaux "nombre d'étages". Il s'est avéré quelque chose comme des plans d'étage dans le bâtiment:



Avec cette approche, nous n'avions pas besoin de refaire radicalement les applications client ou serveur, et en général il semblait que de cette façon la tâche était résolue assez simplement. Cependant, lors de la tentative de mise en œuvre, nous avons rencontré plusieurs problèmes critiques:

  1. Après avoir clarifié les détails avec les concepteurs de niveaux, nous sommes arrivés à la conclusion que le nombre d '«étages» dans un tel schéma peut être impressionnant: certaines des cartes sont situées dans une zone ouverte avec des pentes et des collines douces.
  2. Le calcul des coups lors de la prise de vue d'une couche à une autre est devenu une tâche non triviale. Un exemple de situation problématique est illustré dans la figure ci-dessous: ici, le joueur 1 peut entrer dans le joueur 3, mais pas dans le joueur 2, car le chemin de tir bloque la couche 2, bien que le joueur 2 et le joueur 3 soient sur la même couche.



En un mot, nous avons rapidement abandonné l'idée de diviser l'espace en couches 2D - et décidé que nous agirions en remplaçant complètement le moteur physique.

Ce qui nous a amenés à choisir ce moteur et à l'intégrer aux applications client et serveur existantes.

Deuxième option: sélectionner une bibliothèque prête


Comme le client du jeu est écrit en Unity, nous avons décidé d'envisager la possibilité d'utiliser le moteur physique intégré à Unity par défaut - PhysX. En général, il répondait pleinement aux exigences de nos concepteurs de jeux pour prendre en charge la physique 3D dans le jeu, mais il y avait toujours un problème important. Elle consistait dans le fait que notre application serveur était écrite en C # sans utiliser Unity.

Il y avait une option d'utiliser une bibliothèque C ++ sur un serveur - par exemple, le même PhysX - mais nous ne l'avons pas sérieusement envisagée: en raison de l'utilisation de code natif, il y avait une forte probabilité de plantages du serveur avec cette approche. Également gêné par la faible productivité des opérations Interop et le caractère unique de l'assemblage PhysX purement sous Unity, à l'exclusion de son utilisation dans un autre environnement.

De plus, pour tenter de mettre en œuvre cette idée, d'autres problèmes ont été découverts:

  • manque de support pour la construction d'Unity avec IL2CPP sur Linux, ce qui s'est avéré assez critique, car dans l'une des dernières versions, nous avons changé nos serveurs de jeux en .Net Core 2.1 et les avons déployés sur des machines Linux;
  • manque d'outils pratiques pour le profilage des serveurs sur Unity;
  • faible performance de l'application Unity: nous avions seulement besoin d'un moteur physique, et pas de toutes les fonctionnalités disponibles dans Unity.

De plus, parallèlement à notre projet, la société développait un autre prototype de jeu PvP multijoueur. Ses développeurs ont utilisé les serveurs Unity, et nous avons reçu beaucoup de commentaires négatifs concernant l'approche proposée. En particulier, l'une des plaintes était que les serveurs Unity étaient très «en streaming» et devaient être redémarrés toutes les quelques heures.

La combinaison de ces problèmes nous a également fait abandonner cette idée. Ensuite, nous avons décidé de laisser les serveurs de jeu sur .Net Core 2.1 et de choisir à la place de VolatilePhysics, que nous avons utilisé plus tôt, un autre moteur physique ouvert écrit en C #. À savoir, nous avions besoin d'un moteur C #, car nous avions peur des plantages inattendus lors de l'utilisation de moteurs écrits en C ++.

En conséquence, les moteurs suivants ont été sélectionnés pour les tests:


Les principaux critères pour nous étaient les performances du moteur, la possibilité de son intégration dans Unity et son support: il n'aurait pas dû être abandonné au cas où nous y trouverions des bugs.

Nous avons donc testé les performances des moteurs Bepu Physics v1, Bepu Physics v2 et Jitter Physics, et parmi eux, Bepu Physics v2 s'est avéré être le plus productif. De plus, il est le seul de ces trois à continuer de se développer activement.

Cependant, Bepu Physics v2 ne satisfaisait pas au dernier critère d'intégration restant avec Unity: cette bibliothèque utilise les opérations SIMD et System.Numerics, et comme il n'y a pas de prise en charge SIMD dans les assemblages sur les appareils mobiles avec IL2CPP, tous les avantages des optimisations Bepu ont été perdus. La scène de démonstration dans la construction sur iOS sur l'iPhone 5S était très lente. Nous ne pouvions pas utiliser cette solution sur les appareils mobiles.

Ici, il convient d'expliquer pourquoi nous étions généralement intéressés à utiliser un moteur physique. Dans l'un de mes articles précédents , j'ai expliqué comment nous avons implémenté la partie réseau du jeu et comment fonctionne la prédiction locale des actions des joueurs. En bref, le même code est exécuté sur le client et le serveur - le système ECS. Le client répond aux actions du joueur instantanément, sans attendre de réponse du serveur - la soi-disant prédiction se produit. Lorsqu'une réponse provient du serveur, le client vérifie l'état prévu du monde avec celui reçu, et s'ils ne correspondent pas (mauvaise prédiction), puis sur la base de la réponse du serveur, une réconciliation de ce que le joueur voit est effectuée.

L'idée principale est que nous exécutons le même code à la fois sur le client et sur le serveur, et les situations de mauvaise prédiction sont extrêmement rares. Cependant, aucun des moteurs C # physiques que nous avons trouvés ne répondait à nos exigences lorsque vous travailliez sur des appareils mobiles: par exemple, il ne pouvait pas fournir 30 ips stables sur l'iPhone 5S.

Troisième option, finale: deux moteurs différents


Ensuite, nous avons décidé d'expérimenter: utiliser deux moteurs physiques différents sur le client et le serveur. Nous pensions que dans notre cas cela pourrait fonctionner: nous avons une physique de collision assez simple dans notre jeu, de plus, elle a été implémentée par nous comme un système ECS séparé et ne faisait pas partie du moteur physique. Tout ce dont nous avions besoin du moteur physique était la capacité de faire des reykast et des balayages dans l'espace 3D.

En conséquence, nous avons décidé d'utiliser l'Unité physique intégrée - PhysX - sur le client et Bepu Physics v2 sur le serveur.

Tout d'abord, nous avons mis en évidence l'interface d'utilisation du moteur physique:

Afficher le code
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); } } 


Il y avait différentes implémentations de cette interface sur le client et le serveur: comme déjà mentionné, sur le serveur, nous avons utilisé l'implémentation avec Bepu, et sur le client - Unity.

Ici, il convient de mentionner les nuances du travail avec notre physique sur le serveur.

Du fait que le client reçoit les mises à jour du monde du serveur avec un retard (décalage), le joueur voit le monde un peu différent de ce qu'il voit sur le serveur: il se voit dans le présent et le reste du monde dans le passé. Pour cette raison, il s'avère que le joueur tire localement sur une cible qui se trouve ailleurs sur le serveur. Donc, puisque nous utilisons le système de prédiction d'action du joueur local, nous devons compenser les décalages lors du tournage sur le serveur.



Afin de les compenser, nous devons stocker sur le serveur l'histoire du monde pendant les N dernières millisecondes, et également pouvoir travailler avec des objets de l'histoire, y compris leur physique. Autrement dit, notre système doit être capable de calculer les collisions, les rakcasts et les balayages «dans le passé». En règle générale, les moteurs physiques ne savent pas comment procéder, et Bepu avec PhysX ne fait pas exception. Par conséquent, nous avons dû implémenter ces fonctionnalités nous-mêmes.

Comme nous simulons le jeu avec une fréquence fixe de 30 ticks par seconde, nous avons dû sauvegarder les données du monde physique pour chaque tick. L'idée était de créer non pas une instance de la simulation dans le moteur physique, mais N - pour chaque tick stocké dans l'historique - et d'utiliser le tampon cyclique de ces simulations pour les stocker dans l'historique:

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

Dans notre ECS, il existe un certain nombre de systèmes de lecture-écriture qui fonctionnent avec la physique:

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

ainsi qu'un certain nombre de systèmes en lecture seule, comme un système de calcul des coups des tirs, des explosions de grenades, etc.

À chaque tick de la simulation mondiale, InitPhysicsWorldSystem est exécuté en premier, ce qui définit le numéro de tick actuel (SimulationSlice) sur le moteur physique:

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

La méthode RestoreBodiesFromPreviousTick restaure la position des objets dans le moteur physique au moment du tick précédent à partir des données stockées dans l'historique:

Afficher le code
 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; } } } } 


Après cela, les systèmes SpawnPhysicsDynamicsBodiesSystem et DestroyPhysicsDynamicsBodiesSystem créent ou suppriment des objets dans le moteur physique en fonction de la façon dont ils ont été modifiés lors du dernier tick ECS. Ensuite, UpdatePhysicsTransformsSystem met à jour la position de tous les corps dynamiques en fonction des données dans ECS.

Dès que les données de l'ECS et du moteur physique sont synchronisées, nous calculons le mouvement des objets. Lorsque toutes les opérations de lecture-écriture sont terminées, des systèmes en lecture seule pour calculer la logique du jeu (tirs, explosions, brouillard de guerre ...) entrent en jeu.

Code d'implémentation de SimulationSlice complet pour Bepu Physics:

Afficher le code
 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(); } } } 


De plus, en plus d'implémenter l'historique sur le serveur, nous devions implémenter l'historique de la physique sur le client. Notre client Unity dispose d'un mode d'émulation de serveur - nous l'appelons simulation locale - dans lequel le code serveur s'exécute avec le client. Nous utilisons ce mode pour le prototypage rapide des fonctionnalités du jeu.

Comme Bepu, PhysX ne prend pas en charge l'historique. Ici, nous avons utilisé la même idée en utilisant plusieurs simulations physiques pour chaque tick dans l'historique comme sur le serveur. Cependant, Unity impose ses propres spécificités à l'utilisation de moteurs physiques. Cependant, il convient de noter ici que notre projet a été développé sur Unity 2018.4 (LTS), et certaines API peuvent changer dans les versions plus récentes, il n'y aura donc pas de problèmes comme le nôtre.

Le problème était que Unity ne permettait pas de créer une simulation physique distincte (ou, dans la terminologie PhysX, une scène), nous avons donc implémenté chaque tick dans l'histoire de la physique sur Unity comme une scène distincte.

Une classe wrapper a été écrite sur de telles scènes - 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); } 

Le deuxième problème d'Unity est que tout travail avec la physique se fait via la classe statique Physique, dont l'API ne vous permet pas d'effectuer des rakecasts et des balayages dans une scène particulière. Cette API ne fonctionne qu'avec une seule scène active. Cependant, le moteur PhysX vous permet de travailler avec plusieurs scènes en même temps, il vous suffit d'appeler les bonnes méthodes. Heureusement, Unity a caché de telles méthodes derrière l'interface de classe Physics.cs, il ne restait plus qu'à y accéder. Nous l'avons fait comme ceci:

Afficher le code
 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); 


Sinon, le code pour implémenter UnityPhysicsHistorySlice n'était pas très différent de ce qu'il était dans BepuSimulationSlice.

Ainsi, nous avons obtenu deux implémentations de la physique des jeux: sur le client et sur le serveur.

La prochaine étape consiste à tester.

L’un des indicateurs les plus importants de la «santé» de notre client est le paramètre du nombre de prédictions erronées avec le serveur. Avant de passer à différents moteurs physiques, cet indicateur variait de 1 à 2%, c'est-à-dire que pendant un combat de 9000 ticks (ou 5 minutes), nous nous sommes trompés dans 90-180 ticks de simulation. Nous avons obtenu ces résultats sur plusieurs versions du jeu dans le soft lounge. Après le passage à différents moteurs, nous nous attendions à une forte croissance de cet indicateur - peut-être même plusieurs fois - après tout, nous exécutions maintenant un code différent sur le client et le serveur, et il semblait logique que les erreurs de calcul par différents algorithmes s'accumulent rapidement. Dans la pratique, il s'est avéré que le paramètre de divergence n'a augmenté que de 0,2 à 0,5% et était en moyenne de 2 à 2,5% par bataille, ce qui nous convenait parfaitement.

La plupart des moteurs et technologies que nous avons étudiés utilisaient le même code sur le client et le serveur. Cependant, notre hypothèse avec la possibilité d'utiliser différents moteurs physiques a été confirmée. La raison principale pour laquelle le taux de divergence a augmenté si légèrement est que nous calculons le mouvement des corps dans l'espace et les collisions par l'un de nos systèmes ECS. Ce code est le même sur le client et sur le serveur. Du moteur physique, nous avions besoin d'un calcul rapide des rakecasts et des balayages, et les résultats de ces opérations dans la pratique pour nos deux moteurs ne différaient pas beaucoup.

Que lire


En conclusion, comme d'habitude, voici quelques liens connexes:

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


All Articles