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.
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ódigopublic 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) {
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:
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:
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
:
: