Física para un tirador PvP móvil y cómo nos hicimos amigos de ECS

Hola a todos! En este artículo, hablaremos sobre la experiencia personal trabajando con motores físicos para un juego de disparos multijugador y nos centraremos principalmente en la interacción de la física y el ECS : qué tipo de rastrillo intervinimos durante el trabajo, qué aprendimos y por qué decidimos por soluciones específicas.



Primero, descubramos por qué se necesita un motor físico. No hay una respuesta universal: en cada juego cumple su propósito. Algunos juegos usan motores físicos para simular correctamente el comportamiento de los objetos en el mundo para lograr el efecto de sumergir al jugador. En otros, la física es la base del juego, como, por ejemplo, Angry Birds y Red Faction. También hay "cajas de arena" en las que las leyes físicas difieren de las habituales y, por lo tanto, hacen que el juego sea más interesante e inusual (Portal, A Slower Speed ​​of Light).

Desde el punto de vista de la programación, el motor físico permite simplificar el proceso de simulación del comportamiento de los objetos en un juego. De hecho, es una biblioteca que almacena descripciones de las propiedades físicas de los objetos. En presencia de un motor físico, no necesitamos desarrollar un sistema de interacción entre los cuerpos y las leyes universales por las cuales vivirá el mundo del juego. Esto ahorra un montón de tiempo y esfuerzo de desarrollo.

imagen
El diagrama anterior describe la esencia del reproductor, sus componentes y sus datos, y los sistemas que funcionan con el reproductor y sus componentes. El objeto clave en el diagrama es el jugador: puede moverse en el espacio: componentes de transformación y movimiento, MoveSystem; tiene algo de salud y puede morir: componente Salud, Daño, DamageSystem; después de que aparece la muerte en el punto de reaparición: el componente Transformar para la posición, el RespawnSystem; puede ser invulnerable - componente invencible.

¿Cuáles son las características de la implementación de la física del juego para los tiradores?


No hay interacciones físicas complejas en nuestro juego, pero hay una serie de cosas para las que todavía se necesita un motor físico. Inicialmente, planeamos usarlo para mover a un personaje en el mundo de acuerdo con leyes predeterminadas. Por lo general, esto se hace dando al cuerpo un cierto impulso o velocidad constante, después de lo cual, utilizando el método Simular / Actualizar de la biblioteca, todos los cuerpos registrados en él se simulan exactamente un paso adelante.

En los juegos de disparos, la física 3D a menudo se usa no solo para simular los movimientos del personaje, sino también para procesar correctamente la balística de balas y misiles, saltos, los personajes interactúan entre sí y con el entorno. Si el tirador afirma ser realista y busca transmitir las sensaciones reales del proceso de disparo, solo necesita un motor físico. Cuando un jugador dispara una escopeta a un objetivo, espera obtener experiencia y un resultado lo más cercano posible al que ya conoce de un juego de tiradores a largo plazo; algo radicalmente nuevo probablemente lo sorprenderá desagradablemente.

Pero en el caso de nuestro juego, hay una serie de limitaciones. Dado que nuestro tirador es móvil, no implica interacciones complejas de personajes entre sí y con el mundo circundante, no requiere balística hermosa, destructibilidad, saltar sobre una superficie irregular. Pero al mismo tiempo y por la misma razón, existen requisitos de tráfico muy estrictos. La física 3D en este caso sería redundante: usaría solo una pequeña parte de sus recursos informáticos y generaría datos innecesarios, lo que en una red móvil y la sincronización constante del cliente con el servidor a través de UDP ocuparía demasiado espacio. Aquí vale la pena recordar que en nuestro modelo de red todavía existen cosas como Predicción y Reconciliación , que también implican acuerdos en el cliente. Como resultado, obtenemos que nuestra física debe funcionar lo más rápido posible para poder lanzar y trabajar con éxito en dispositivos móviles, sin interferir con el render y otros subsistemas del cliente.

Entonces, la física 3D no nos convenía. Pero aquí vale la pena recordar que incluso si el juego parece tridimensional, no es un hecho que la física también sea tridimensional: todo determina la naturaleza de la interacción de los objetos entre sí. A menudo, los efectos que no pueden ser cubiertos por la física 2D se personalizan, es decir, se escribe una lógica que parece interacciones tridimensionales, o simplemente se reemplazan con efectos visuales que no afectan el juego. En Heroes of the Storm, Defense of the Ancients, League of Legends, la física bidimensional puede proporcionar todas las características de juego del juego sin afectar la calidad de la imagen o el sentimiento de credibilidad creado por los diseñadores y artistas del mundo. Entonces, por ejemplo, en estos juegos hay personajes que saltan, pero no tiene sentido físico en la altura de su salto, por lo que se reduce a una simulación bidimensional y al establecimiento de algún tipo de bandera como _isInTheAir cuando el personaje está en el aire: se tiene en cuenta al calcular la lógica.

Entonces se decidió usar la física 2D. Escribimos el juego en Unity, pero el servidor utiliza .net sin Unity, que Unity no entiende. Dado que la mayor parte del código de simulación se revuelve entre el cliente y el servidor, comenzamos a buscar algo multiplataforma, es decir, una biblioteca física escrita en C # puro sin usar código nativo para eliminar el peligro de bloquear plataformas móviles. Además, teniendo en cuenta los detalles del trabajo de los tiradores , en particular, los rebobinados constantes en el servidor para determinar dónde disparó el jugador, era importante para nosotros que la biblioteca pudiera trabajar con la historia, es decir, pudiéramos ver de forma barata la posición de los cuerpos N cuadros en el tiempo . Y, por supuesto, el proyecto no debe abandonarse: es importante que el autor lo respalde y pueda corregir rápidamente los errores, si se encuentran algunos durante la operación.

Al final resultó que, en ese momento muy pocas bibliotecas podrían satisfacer nuestros requisitos. De hecho, solo uno era adecuado para nosotros: VolatilePhysics .

La biblioteca es notable porque funciona con las soluciones Unity y Unity-less, y también le permite hacer rakecasts en el estado pasado de objetos fuera de la caja, es decir. Adecuado para la lógica del tirador. Además, la conveniencia de la biblioteca radica en el hecho de que el mecanismo para controlar el inicio de la simulación de Simulate () le permite producirlo en cualquier momento cuando el cliente lo necesita. Y otra característica: la capacidad de escribir datos adicionales en el cuerpo físico. Esto puede ser útil al abordar un objeto de una simulación en los resultados de reykast; sin embargo, esto reduce en gran medida el rendimiento.

Después de hacer un par de pruebas y asegurarnos de que el cliente y el servidor interactúen bien con VolatilePhysics sin fallas, optamos por ello.

Cómo ingresamos a la biblioteca en la forma habitual de trabajar con ECS y qué salió de ella


El primer paso cuando se trabaja con VolatilePhysics es crear el mundo físico de VoltWorld. Es una clase proxy, con la cual se lleva a cabo el trabajo principal: ajuste, simulación de datos sobre objetos, reykast, etc. Lo envolvimos en una fachada especial para que en el futuro podamos cambiar la implementación de la biblioteca a otra cosa. El código de la fachada se veía así:

Ver código
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; } } 


Al crear el mundo, se indica la magnitud de la historia: la cantidad de estados del mundo que almacenará la biblioteca. En nuestro caso, su número era de 32:30 cuadros por segundo, lo necesitaremos en función del requisito de actualizar la lógica y 2 adicionales en caso de que superemos los límites del proceso de depuración. El código también tiene en cuenta los métodos de lanzamiento externo que generan cuerpos físicos y varios tipos de reykast.

Como recordamos de artículos anteriores , el mundo de ECS esencialmente gira en torno a una llamada regular para ejecutar métodos para todos los sistemas incluidos en él. En los lugares correctos de cada sistema, utilizamos llamadas a nuestra fachada. Inicialmente, no escribimos ningún lote para desafiar el motor físico, aunque hubo tales pensamientos. Dentro de la fachada, se realiza una llamada a Update () del mundo físico, y la biblioteca simula todas las interacciones de objetos que ocurrieron por fotograma.

Por lo tanto, el trabajo con la física se reduce a dos componentes: el movimiento uniforme de los cuerpos en el espacio en un cuadro y los muchos rakast necesarios para disparar, la operación adecuada de los efectos y muchas otras cosas. Los Reykasts son especialmente relevantes en la historia del estado de los cuerpos físicos.

Según los resultados de nuestras pruebas, nos dimos cuenta rápidamente de que la biblioteca funciona muy mal a diferentes velocidades y, a cierta velocidad, los cuerpos comienzan a atravesar fácilmente las paredes. No hay configuraciones asociadas con la detección de colisión continua para resolver este problema en nuestro motor. Pero no había alternativas a nuestra solución en el mercado en ese momento, así que tuve que crear mi propia versión de objetos en movimiento en todo el mundo y sincronizar los datos físicos con ECS. Entonces, por ejemplo, nuestro código para el sistema de movimiento es el siguiente:

Ver código
 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; } } 


La idea es que antes de que cada personaje se mueva, hacemos un CircleCast en la dirección de su movimiento para determinar si hay un obstáculo frente a él. CircleCast es necesario porque las proyecciones de los personajes en el juego representan un círculo, y no queremos que se atasquen en las esquinas entre diferentes geometrías. Luego consideramos el incremento de la velocidad y asignamos este valor al objeto del mundo físico como su velocidad en un cuadro. El siguiente paso es llamar al método de simulación del motor físico Update (), que mueve todos los objetos que necesitamos, registrando simultáneamente el estado anterior en la historia. Después de completar la simulación dentro del motor, leemos estos datos simulados, los copiamos al componente Transformar de nuestro ECS y luego continuamos trabajando con ellos, en particular, los enviamos a través de la red.

Este enfoque en la actualización de la física con pequeños fragmentos controlados de datos sobre la velocidad de movimiento del personaje resultó ser muy efectivo para tratar las discrepancias en física en el cliente y el servidor. Y dado que nuestra física no es determinista, es decir, con los mismos datos de entrada, el resultado de la simulación puede variar, ha habido muchas discusiones sobre si vale la pena usarlo y si alguien en la industria hace algo similar, Tener un motor físico determinista en la mano. Afortunadamente, encontramos un excelente informe de los desarrolladores de NetherRealm Studios en la Game Developers Conference sobre el componente de red de sus juegos y nos dimos cuenta de que ese enfoque realmente se lleva a cabo. Después de haber ensamblado completamente el sistema y haberlo ejecutado en varias pruebas, obtuvimos alrededor de 50 predicciones falsas para 9000 ticks, es decir, durante la batalla de cinco minutos. Tal cantidad de predicciones perdidas se nivela fácilmente mediante el mecanismo de Reconciliación y la interpolación visual de la posición del jugador. Los errores que ocurren durante las actualizaciones manuales frecuentes de la física usando sus propios datos son insignificantes, por lo tanto, la interpolación visual puede tener lugar con bastante rapidez: solo se necesita para que no ocurra un salto visual en el modelo de caracteres.

Para verificar si los estados del cliente y el servidor coinciden, utilizamos una clase autoescrita de la siguiente forma:

Ver código
 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; } } 


Si es necesario, puede automatizarse, pero no lo hicimos, aunque lo pensamos en el futuro.

Código de comparación de transformación:

Ver código
 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; } 



Primeras dificultades


No hubo problemas con la simulación de movimiento, aunque se podía proyectar en un plano 2D; la física en tales casos funcionó muy bien, pero en un momento los diseñadores de juegos vinieron y dijeron: "¡Queremos granadas!" Y pensamos que nada cambiaría mucho , ¿por qué no simular el vuelo 3D del cuerpo físico con solo datos 2D disponibles?

E introdujeron el concepto de altura para algunos objetos.

Para los cuerpos abandonados, la ley de altitud cambia con el tiempo, pasan por las lecciones de física en octavo grado, por lo que la decisión sobre balística resultó ser trivial. Pero la solución con colisiones ya no era tan trivial. Imaginemos este caso: una granada durante el vuelo debería chocar con una pared o volar sobre ella dependiendo de su altura actual y la altura de la pared. Resolveremos el problema solo en el mundo bidimensional, donde la granada está representada por un círculo y la pared por un rectángulo.


Vista de la geometría de los objetos para resolver el problema.

En primer lugar, desactivamos la interacción del cuerpo dinámico de una granada con otros cuerpos estáticos y dinámicos. Esto es necesario para enfocarse en la meta. En nuestra tarea, una granada debería poder atravesar otros objetos y "volar" sobre una pared cuando sus proyecciones en un plano bidimensional se cruzan entre sí. En una interacción normal, dos objetos no pueden cruzarse entre sí y, sin embargo, en el caso de una granada con una lógica y altura de movimiento personalizadas, permitimos que lo haga bajo ciertas condiciones.

Introdujimos un componente separado GrenadeMovement para la granada, en el que presentamos el concepto de altura:

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

Ahora la granada tiene una coordenada de altura, pero esta información no le da nada al resto del mundo. Por lo tanto, decidimos hacer trampa y agregamos la siguiente condición: una granada puede volar sobre paredes, pero solo de cierta altura. Por lo tanto, toda la definición de colisiones se redujo a verificar las colisiones de proyección y comparar la altura de la pared con el valor del campo GrenadeMovement.Height. Si la altura de vuelo de la granada es menor, colisiona con la pared, de lo contrario puede continuar tranquilamente moviéndose a lo largo de su camino, incluso en el espacio 2D.

En la primera iteración, la granada simplemente cayó al encontrar intersecciones, pero luego agregamos colisiones elásticas, y comenzó a comportarse casi indistinguible del resultado que obtendríamos en 3D.

El código completo para calcular la trayectoria de la granada y las colisiones elásticas se muestra a continuación:

Ver código
 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; } } } 


Ellos escribieron física. Que sigue


El juego vive, se desarrolla y, con el tiempo, nos fue necesario depurar de alguna manera lo que está sucediendo con su motor físico en el servidor. En uno de los artículos, ya describí en detalle la depuración de ECS en el servidor . En cuanto a la física, tenemos un editor visual directo que toma datos de JSON, cuya estructura se genera junto con el resto del diseño de ECS. Este editor se ve así:



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

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

- , 3D-, , .

, , , . , , ECS .

Enlaces utiles


:


:

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


All Articles