Cómo mejoramos la mecánica de los cálculos balísticos para un tirador móvil con un algoritmo de compensación de latencia de red

Cómo mejoramos la mecánica de los cálculos balísticos para un tirador móvil con un algoritmo de compensación de latencia de red

Hola, soy Nikita Brizhak, desarrolladora de servidores de Pixonic. Hoy me gustaría hablar sobre cómo compensar el retraso en el modo multijugador móvil.

Se han escrito muchos artículos sobre la compensación del retraso del servidor, incluso en ruso. Esto no es sorprendente, ya que esta tecnología se ha utilizado activamente en la creación de FPS multijugador desde finales de los años 90. Por ejemplo, puedes recordar el mod QuakeWorld, que fue uno de los primeros en usarlo.

También lo usamos en nuestro shooter multijugador móvil Dino Squad.

En este artículo, mi objetivo no es repetir lo que ya se ha escrito miles de veces, sino contar cómo implementamos la compensación de retraso en nuestro juego, teniendo en cuenta nuestra tecnología y las características principales del juego.

Algunas palabras sobre nuestra corteza y la tecnología.

Dino Squad es un shooter PvP móvil en red. Los jugadores controlan dinosaurios equipados con una variedad de armas y luchan entre sí en equipos de 6 contra 6.

Tanto el cliente como el servidor están basados ​​en Unity. La arquitectura es bastante clásica para los shooters: el servidor es autoritario y la predicción del cliente funciona en los clientes. La simulación del juego se escribe utilizando ECS interno y se utiliza tanto en el servidor como en el cliente.

Si es la primera vez que oye hablar de la compensación de retrasos, aquí le ofrecemos un breve recorrido por el tema.

En los juegos FPS multijugador, la partida suele simularse en un servidor remoto. Los jugadores envían su entrada (información sobre las teclas presionadas) al servidor y, en respuesta, el servidor les envía un estado actualizado del juego teniendo en cuenta los datos recibidos. Con este esquema de interacción, el retraso entre presionar la tecla de avance y el momento en que el personaje del jugador se mueve en la pantalla siempre será mayor que el ping.

Mientras que en las redes locales este retraso (popularmente llamado input lag) puede pasar desapercibido, al jugar a través de Internet crea una sensación de “deslizarse sobre el hielo” al controlar a un personaje. Este problema es doblemente relevante para las redes móviles, donde el caso en el que el ping del reproductor es de 200 ms todavía se considera una conexión excelente. A menudo, el ping puede ser de 350, 500 o 1000 ms. Entonces resulta casi imposible jugar un shooter rápido con retraso de entrada.

La solución a este problema es la predicción de simulación del lado del cliente. Aquí el propio cliente aplica la entrada al personaje del jugador, sin esperar una respuesta del servidor. Y cuando se recibe la respuesta, simplemente compara los resultados y actualiza las posiciones de los oponentes. El retraso entre presionar una tecla y mostrar el resultado en pantalla en este caso es mínimo.

Es importante comprender el matiz: el cliente siempre se dibuja a sí mismo según su última entrada, y los enemigos, con un retraso de la red, según el estado anterior a partir de los datos del servidor. Es decir, cuando dispara a un enemigo, el jugador lo ve en el pasado en relación con él mismo. Más sobre la predicción del cliente escribimos antes.

Por lo tanto, la predicción del cliente resuelve un problema, pero crea otro: si un jugador dispara al punto donde estaba el enemigo en el pasado, en el servidor, cuando dispara al mismo punto, es posible que el enemigo ya no esté en ese lugar. La compensación del retraso del servidor intenta resolver este problema. Cuando se dispara un arma, el servidor restaura el estado del juego que el jugador vio localmente en el momento del disparo y comprueba si realmente pudo haber alcanzado al enemigo. Si la respuesta es “sí”, se cuenta el impacto, incluso si el enemigo ya no está en el servidor en ese momento.

Armados con este conocimiento, comenzamos a implementar la compensación del retraso del servidor en Dino Squad. En primer lugar, teníamos que entender cómo restaurar en el servidor lo que vio el cliente. ¿Y qué es exactamente lo que hay que restaurar? En nuestro juego, los impactos de armas y habilidades se calculan mediante raycasts y superposiciones, es decir, mediante interacciones con los colisionadores físicos del enemigo. En consecuencia, necesitábamos reproducir en el servidor la posición de estos colisionadores, que el jugador "vio" localmente. En ese momento estábamos usando la versión 2018.x de Unity. La API de física allí es estática, el mundo físico existe en una sola copia. No hay forma de guardar su estado y luego restaurarlo desde el cuadro. ¿Entonces lo que hay que hacer?

La solución estaba en la superficie, todos sus elementos ya habíamos sido utilizados por nosotros para resolver otros problemas:

  1. Para cada cliente, necesitamos saber en qué momento vio a sus oponentes cuando presionó las teclas. Ya escribimos esta información en el paquete de entrada y la usamos para ajustar la predicción del cliente.
  2. Necesitamos poder almacenar el historial de los estados del juego. Es en él donde mantendremos las posiciones de nuestros oponentes (y por tanto de sus colisionadores). Ya teníamos un historial de estado en el servidor, lo usamos para construir deltas. Conociendo el momento adecuado, podríamos encontrar fácilmente el estado correcto en la historia.
  3. Ahora que tenemos el estado del juego en la historia, necesitamos poder sincronizar los datos del jugador con el estado del mundo físico. Los colisionadores existentes se mueven, los que faltan se crean, los innecesarios se destruyen. Esta lógica también ya estaba escrita y constaba de varios sistemas ECS. Lo usamos para albergar varias salas de juegos en un proceso de Unity. Y como el mundo físico es uno por proceso, hubo que reutilizarlo entre salas. Antes de cada tic de la simulación, "restablecimos" el estado del mundo físico y lo reiniciamos con datos de la sala actual, intentando reutilizar los objetos del juego Unity tanto como sea posible a través de un sistema de agrupación inteligente. Todo lo que quedaba era invocar la misma lógica para el estado del juego del pasado.

Al juntar todos estos elementos, obtuvimos una "máquina del tiempo" que podría hacer retroceder el estado del mundo físico al momento adecuado. El código resultó ser simple:

public class TimeMachine : ITimeMachine
{
     //История игровых состояний
     private readonly IGameStateHistory _history;

     //Текущее игровое состояние на сервере
     private readonly ExecutableSystem[] _systems;

     //Набор систем, расставляющих коллайдеры в физическом мире 
     //по данным из игрового состояния
     private readonly GameState _presentState;

     public TimeMachine(IGameStateHistory history, GameState presentState, ExecutableSystem[] timeInitSystems)
     {
         _history = history; 
         _presentState = presentState;
         _systems = timeInitSystems;  
     }

     public GameState TravelToTime(int tick)
     {
         var pastState = tick == _presentState.Time ? _presentState : _history.Get(tick);
         foreach (var system in _systems)
         {
             system.Execute(pastState);
         }
         return pastState;
     }
}

Todo lo que quedaba era descubrir cómo usar esta máquina para compensar fácilmente los disparos y las habilidades.

En el caso más simple, cuando la mecánica se basa en un solo hitscan, todo parece estar claro: antes de que el jugador dispare, necesita hacer retroceder el mundo físico al estado deseado, hacer un raycast, contar el acierto o el fallo, y devolver el mundo al estado inicial.

¡Pero hay muy pocas mecánicas de este tipo en Dino Squad! La mayoría de las armas del juego crean proyectiles: balas de larga duración que vuelan durante varios tics de simulación (en algunos casos, docenas de tics). ¿Qué hacer con ellos, a qué hora deberían volar?

В artículo antiguo Sobre la pila de red Half-Life, los chicos de Valve hicieron la misma pregunta y su respuesta fue la siguiente: la compensación del retraso del proyectil es problemática y es mejor evitarla.

No teníamos esta opción: las armas basadas en proyectiles eran una característica clave del diseño del juego. Así que teníamos que pensar en algo. Después de una lluvia de ideas, formulamos dos opciones que parecían funcionar:

1. Vinculamos el proyectil a la época del jugador que lo creó. En cada tic de la simulación del servidor, por cada bala de cada jugador, retrocedemos el mundo físico al estado del cliente y realizamos los cálculos necesarios. Este enfoque hizo posible tener una carga distribuida en el servidor y un tiempo de vuelo predecible de los proyectiles. La previsibilidad era especialmente importante para nosotros, ya que teníamos todos los proyectiles, incluidos los enemigos, predichos en el cliente.

Cómo mejoramos la mecánica de los cálculos balísticos para un tirador móvil con un algoritmo de compensación de latencia de red
En la imagen, el jugador en el tick 30 dispara un misil con anticipación: ve en qué dirección corre el enemigo y conoce la velocidad aproximada del misil. Localmente ve que ha dado en el blanco en el tic 33. Gracias a la compensación de retraso, también aparecerá en el servidor.

2. Hacemos todo igual que en la primera opción, pero, habiendo contado un tick de la simulación de bala, no nos detenemos, sino que continuamos simulando su vuelo dentro del mismo tick del servidor, acercando cada vez su tiempo al servidor. marca uno por uno y actualiza las posiciones del colisionador. Hacemos esto hasta que suceda una de dos cosas:

  • La bala ha expirado. Esto significa que los cálculos han terminado, podemos contar un fallo o un acierto. ¡Y esto es en el mismo tic en el que se disparó el tiro! Para nosotros esto fue a la vez un plus y un menos. Una ventaja, porque para el jugador que dispara esto redujo significativamente el retraso entre el impacto y la disminución de la salud del enemigo. La desventaja es que se observó el mismo efecto cuando los oponentes dispararon contra el jugador: el enemigo, al parecer, solo disparó un cohete lento y el daño ya estaba contado.
  • La bala ha llegado a la hora del servidor. En este caso, su simulación continuará en el siguiente tick del servidor sin ninguna compensación de retraso. Para proyectiles lentos, esto teóricamente podría reducir la cantidad de retrocesos físicos en comparación con la primera opción. Al mismo tiempo, la carga desigual en la simulación aumentó: el servidor estaba inactivo o en un tick del servidor estaba calculando una docena de ticks de simulación para varias balas.

Cómo mejoramos la mecánica de los cálculos balísticos para un tirador móvil con un algoritmo de compensación de latencia de red
El mismo escenario que en la imagen anterior, pero calculado según el segundo esquema. El misil "alcanzó" la hora del servidor en el mismo tic en el que se produjo el disparo, y el impacto se puede contar desde el siguiente tic. En este caso, en el tic 31 ya no se aplica la compensación de retraso.

En nuestra implementación, estos dos enfoques diferían en solo un par de líneas de código, por lo que creamos ambos y durante mucho tiempo existieron en paralelo. Dependiendo de la mecánica del arma y de la velocidad de la bala, elegimos una u otra opción para cada dinosaurio. El punto de inflexión aquí fue la aparición en el juego de mecánicas como "si golpeas al enemigo tantas veces en tal o cual momento, obtienes tal o cual bonificación". Cualquier mecánica en la que el momento en el que el jugador golpea al enemigo desempeñaba un papel importante se negaba a trabajar con el segundo enfoque. Así que terminamos eligiendo la primera opción, y ahora se aplica a todas las armas y todas las habilidades activas del juego.

Por otra parte, vale la pena plantear la cuestión del desempeño. Si pensabas que todo esto ralentizaría las cosas, te respondo: así es. Unity es bastante lento a la hora de mover colisionadores y encenderlos y apagarlos. En Dino Squad, en el "peor de los casos", puede haber varios cientos de proyectiles existentes simultáneamente en combate. Mover colisionadores para contar cada proyectil individualmente es un lujo inasequible. Por lo tanto, era absolutamente necesario que minimizáramos el número de "retrocesos" físicos. Para hacer esto, creamos un componente separado en ECS en el que registramos el tiempo del jugador. Lo agregamos a todas las entidades que requieren compensación de retraso (proyectiles, habilidades, etc.). Antes de comenzar a procesar dichas entidades, las agrupamos en este momento y las procesamos juntas, haciendo retroceder el mundo físico una vez para cada grupo.

En esta etapa tenemos un sistema que funciona en general. Su código en una forma algo simplificada:

public sealed class LagCompensationSystemGroup : ExecutableSystem
{
     //Машина времени
     private readonly ITimeMachine _timeMachine;

     //Набор систем лагкомпенсации
     private readonly LagCompensationSystem[] _systems;
     
     //Наша реализация кластеризатора
     private readonly TimeTravelMap _travelMap = new TimeTravelMap();

    public LagCompensationSystemGroup(ITimeMachine timeMachine, 
        LagCompensationSystem[] lagCompensationSystems)
     {
         _timeMachine = timeMachine;
         _systems = lagCompensationSystems;
     }

     public override void Execute(GameState gs)
     {
         //На вход кластеризатор принимает текущее игровое состояние,
         //а на выход выдает набор «корзин». В каждой корзине лежат энтити,
         //которым для лагкомпенсации нужно одно и то же время из истории.
         var buckets = _travelMap.RefillBuckets(gs);

         for (int bucketIndex = 0; bucketIndex < buckets.Count; bucketIndex++)
         {
             ProcessBucket(gs, buckets[bucketIndex]);
         }

         //В конце лагкомпенсации мы восстанавливаем физический мир 
         //в исходное состояние
         _timeMachine.TravelToTime(gs.Time);
     }

     private void ProcessBucket(GameState presentState, TimeTravelMap.Bucket bucket)
     {
         //Откатываем время один раз для каждой корзины
         var pastState = _timeMachine.TravelToTime(bucket.Time);

         foreach (var system in _systems)
         {
               system.PastState = pastState;
               system.PresentState = presentState;

               foreach (var entity in bucket)
               {
                   system.Execute(entity);
               }
          }
     }
}

Ya sólo quedaba configurar los detalles:

1. Comprenda cuánto limitar la distancia máxima de movimiento en el tiempo.

Para nosotros era importante hacer que el juego fuera lo más accesible posible en condiciones de malas redes móviles, por lo que limitamos la historia a un margen de 30 ticks (con una frecuencia de ticks de 20 Hz). Esto permite a los jugadores golpear a sus oponentes incluso con pings muy altos.

2. Determinar qué objetos se pueden mover en el tiempo y cuáles no.

Por supuesto, estamos moviendo a nuestros oponentes. Pero los escudos de energía instalables, por ejemplo, no lo son. Decidimos que era mejor dar prioridad a la capacidad defensiva, como suele hacerse en los shooters online. Si el jugador ya ha colocado un escudo en el presente, las balas del pasado con retraso compensado no deberían atravesarlo.

3. Decidir si es necesario compensar las habilidades de los dinosaurios: morder, golpear con la cola, etc. Decidimos qué era necesario y los procesamos de acuerdo con las mismas reglas que las balas.

4. Determine qué hacer con los colisionadores del jugador para quien se realiza la compensación del retraso. En el buen sentido, su posición no debería retroceder hacia el pasado: el jugador debería verse a sí mismo en el mismo momento en el que se encuentra ahora en el servidor. Sin embargo, también revertimos los colisionadores del jugador que dispara, y hay varias razones para ello.

Primero, mejora la agrupación: podemos usar el mismo estado físico para todos los jugadores con pings cercanos.

En segundo lugar, en todos los raycasts y superposiciones siempre excluimos los colisionadores del jugador propietario de las habilidades o proyectiles. En Dino Squad, los jugadores controlan dinosaurios, que tienen una geometría bastante no estándar para los estándares de los shooters. Incluso si el jugador dispara en un ángulo inusual y la trayectoria de la bala pasa a través del colisionador de dinosaurios del jugador, la bala lo ignorará.

En tercer lugar, calculamos las posiciones del arma del dinosaurio o el punto de aplicación de la habilidad utilizando datos del ECS incluso antes de que comience la compensación del retraso.

Como resultado, la posición real de los colisionadores del reproductor con compensación de retraso no es importante para nosotros, por lo que tomamos un camino más productivo y al mismo tiempo más sencillo.

La latencia de la red no se puede simplemente eliminar, sólo se puede enmascarar. Como cualquier otro método de disfraz, la compensación del retraso del servidor tiene sus desventajas. Mejora la experiencia de juego del jugador que dispara a expensas del jugador al que disparan. Para Dino Squad, sin embargo, la elección aquí era obvia.

Por supuesto, todo esto también tuvo que pagarse por la creciente complejidad del código del servidor en su conjunto, tanto para los programadores como para los diseñadores de juegos. Si antes la simulación era una simple llamada secuencial de sistemas, entonces, con la compensación del retraso, aparecían bucles y ramas anidados. También nos esforzamos mucho para que fuera cómodo trabajar con él.

En la versión 2019 (y tal vez un poco antes), Unity agregó soporte completo para escenas físicas independientes. Los implementamos en el servidor casi inmediatamente después de la actualización, porque queríamos deshacernos rápidamente del mundo físico común a todas las salas.

Le dimos a cada sala de juegos su propia escena física y así eliminamos la necesidad de "borrar" la escena de los datos de la sala vecina antes de calcular la simulación. En primer lugar, dio un aumento significativo de la productividad. En segundo lugar, permitió deshacerse de toda una clase de errores que surgían si el programador cometía un error en el código de limpieza de la escena al agregar nuevos elementos del juego. Estos errores eran difíciles de depurar y, a menudo, provocaban que el estado de los objetos físicos de la escena de una habitación "fluyera" hacia otra habitación.

Además, investigamos un poco si las escenas físicas podrían usarse para almacenar la historia del mundo físico. Es decir, condicionalmente, asignar no una escena a cada habitación, sino 30 escenas, y crear con ellas un buffer cíclico en el que almacenar la historia. En general, la opción resultó funcionar, pero no la implementamos: no mostró ningún aumento loco en la productividad, pero requirió cambios bastante arriesgados. Era difícil predecir cómo se comportaría el servidor al trabajar durante mucho tiempo con tantas escenas. Por lo tanto, seguimos la regla: “Si no está roto, no lo arregles".

Fuente: habr.com

Añadir un comentario