Predicción física del lado del cliente en Unity

imagen

TL; DR


Creé una demostración que muestra cómo implementar la predicción del lado del cliente del movimiento físico de un jugador en Unity: GitHub .

Introduccion


A principios de 2012, escribí una publicación sobre cómo implementar pronósticos en el lado del cliente del movimiento físico de un jugador en Unity. Gracias a Physics.Simulate (), esa torpe solución que describí ya no es necesaria. La publicación anterior sigue siendo una de las más populares en mi blog, pero para la Unidad moderna esta información ya es incorrecta. Por lo tanto, estoy lanzando la versión 2018.

¿Qué hay del lado del cliente?


En los juegos competitivos multijugador, se debe evitar hacer trampa siempre que sea posible. Por lo general, esto significa que se usa un modelo de red con un servidor autoritario: los clientes envían la información ingresada al servidor, y el servidor convierte esta información en el movimiento de un jugador, y luego envía una instantánea del estado del jugador al cliente. En este caso, hay un retraso entre presionar la tecla y mostrar el resultado, lo cual es inaceptable para cualquier juego activo. La predicción del lado del cliente es una técnica muy popular que oculta el retraso, prediciendo cuál será el movimiento resultante y enseñándolo inmediatamente al jugador. Cuando el cliente recibe los resultados del servidor, los compara con lo que predijo el cliente, y si difieren, el pronóstico era erróneo y debe corregirse.

Las instantáneas recibidas del servidor siempre provienen del pasado con respecto al estado predicho del cliente (por ejemplo, si la transferencia de datos del cliente al servidor y viceversa toma 150 ms, entonces cada instantánea se retrasará al menos 150 ms). Como resultado de esto, cuando el cliente necesita corregir el pronóstico incorrecto, debe retroceder a este punto en el pasado y luego reproducir toda la información ingresada en el espacio para volver a donde está. Si el movimiento del jugador en el juego se basa en la física, entonces Physics.Simulate () es necesario para simular varios ciclos en un cuadro. Si solo se usan los Controladores de personaje (o el lanzamiento de cápsulas, etc.) al mover al jugador, entonces puedes prescindir de Physics.Simulate (), y supongo que el rendimiento será mejor.

Usaré Unity para recrear una demostración de red llamada "Zen of Networked Physics de Glenn Fiedler ", que he disfrutado durante mucho tiempo. El jugador tiene un cubo físico sobre el cual puede ejercer fuerza, empujándolo a la escena. La demostración simula varias condiciones de red, incluida la demora y la pérdida de paquetes.

Llegar al trabajo


Lo primero que debe hacer es desactivar la simulación automática de física. Aunque Physics.Simulate () nos permite decirle al sistema físico cuándo comenzar la simulación, por defecto realiza la simulación automáticamente en función de un delta de tiempo de proyecto fijo. Por lo tanto, lo deshabilitaremos en Edición-> Configuración del proyecto-> Física desmarcando la casilla " Simulación automática ".

Para comenzar, crearemos una implementación simple para un solo usuario. La entrada se muestrea (w, a, s, d para moverse y el espacio para saltar), y todo se reduce a las fuerzas simples aplicadas al Rigidbody usando AddForce ().

public class Logic : MonoBehaviour { public GameObject player; private float timer; private void Start() { this.timer = 0.0f; } private void Update() { this.timer += Time.deltaTime; while (this.timer >= Time.fixedDeltaTime) { this.timer -= Time.fixedDeltaTime; Inputs inputs; inputs.up = Input.GetKey(KeyCode.W); inputs.down = Input.GetKey(KeyCode.S); inputs.left = Input.GetKey(KeyCode.A); inputs.right = Input.GetKey(KeyCode.D); inputs.jump = Input.GetKey(KeyCode.Space); this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs); Physics.Simulate(Time.fixedDeltaTime); } } } 


El jugador se mueve mientras la red no está en uso

Envío de entrada al servidor


Ahora tenemos que enviar la entrada al servidor, que también ejecutará este código de movimiento, tomará una instantánea del estado del cubo y lo enviará de vuelta al cliente.

 // client private void Update() { this.timer += Time.deltaTime; while (this.timer >= Time.fixedDeltaTime) { this.timer -= Time.fixedDeltaTime; Inputs inputs = this.SampleInputs(); InputMessage input_msg; input_msg.inputs = inputs; input_msg.tick_number = this.tick_number; this.SendToServer(input_msg); this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs); Physics.Simulate(Time.fixedDeltaTime); ++this.tick_number; } } 

Nada especial aquí hasta ahora, lo único a lo que quiero prestar atención es agregar la variable tick_number. Es necesario para que cuando el servidor envíe instantáneas del estado del cubo al cliente, podamos averiguar qué tacto del cliente corresponde a este estado, de modo que podamos comparar este estado con el cliente predicho (que agregaremos un poco más adelante).

 // server private void Update() { while (this.HasAvailableInputMessages()) { InputMessage input_msg = this.GetInputMessage(); Rigidbody rigidbody = player.GetComponent<Rigidbody>(); this.AddForcesToPlayer(rigidbody, input_msg.inputs); Physics.Simulate(Time.fixedDeltaTime); StateMessage state_msg; state_msg.position = rigidbody.position; state_msg.rotation = rigidbody.rotation; state_msg.velocity = rigidbody.velocity; state_msg.angular_velocity = rigidbody.angularVelocity; state_msg.tick_number = input_msg.tick_number + 1; this.SendToClient(state_msg); } } 

Todo es simple: el servidor espera mensajes de entrada, cuando lo recibe, simula un ciclo de reloj. Luego toma una instantánea del estado resultante del cubo y lo envía de vuelta al cliente. Puede observar que tick_number en el mensaje de estado es uno mayor que tick_number en el mensaje de entrada. Esto se hace porque personalmente es intuitivamente más conveniente para mí pensar en el "estado del jugador en el tacto 100" como el "estado del jugador al comienzo del tacto 100". Por lo tanto, el estado del jugador en la medida 100 en combinación con la entrada del jugador en la medida 100 crea un nuevo estado para el jugador en la medida 101.

Estado n + Entrada n = Estado n + 1


No estoy diciendo que debas tomarlo de la misma manera, lo principal es la constancia del enfoque.

También debe decirse que no envío estos mensajes a través de un socket real, sino que los imito escribiéndolos en la cola, simulando el retraso y la pérdida de paquetes. La escena contiene dos cubos físicos: uno para el cliente y otro para el servidor. Al actualizar el cubo del cliente, desactivo el GameObject del cubo del servidor y viceversa.

Sin embargo, no simulo el rebote de la red y la entrega de paquetes en el orden incorrecto, por lo que supongo que cada mensaje de entrada recibido es más nuevo que el anterior. Esta imitación es necesaria para ejecutar simplemente el "cliente" y el "servidor" en una instancia de Unity, de modo que podamos combinar los cubos de servidor y cliente en una escena.

También puede observar que si el mensaje de entrada se descarta y no llega al servidor, el servidor simula menos ciclos de reloj que el cliente y, por lo tanto, creará un estado diferente. Esto es cierto, pero incluso si simulamos estas omisiones, la entrada podría ser incorrecta, lo que también conduciría a un estado diferente. Nos ocuparemos de este problema más tarde.

También se debe agregar que en este ejemplo solo hay un cliente, lo que simplifica el trabajo. Si tuviéramos varios clientes, necesitaríamos a) al llamar a Physics.Simulate () para verificar que solo el cubo de un jugador esté habilitado en el servidor, o b) si el servidor recibió información de varios cubos, simúlelos todos juntos.


Retraso 75 ms (150 ms ida y vuelta)
0% paquetes perdidos
Cubo amarillo - jugador del servidor
Cubo azul: la última instantánea recibida por el cliente

Todo se ve bien hasta ahora, pero fui un poco selectivo con lo que grabé en el video para ocultar un problema bastante serio.

Fracaso de determinación


Echa un vistazo ahora a esto:


Ay ...

Este video fue grabado sin perder paquetes, sin embargo, las simulaciones aún varían con la misma entrada. No entiendo bien por qué sucede esto: PhysX debería ser bastante determinista, por lo que me parece sorprendente que las simulaciones a menudo diverjan. Esto puede deberse al hecho de que constantemente habilito y deshabilito los cubos de GameObject, es decir, es posible que el problema disminuya al usar dos instancias diferentes de Unity. Puede ser un error, si lo ve en el código de GitHub, avíseme.

Sea como fuere, los pronósticos incorrectos son un hecho esencial en el pronóstico del lado del cliente, así que tratemos con ellos.

¿Puedo rebobinar?


El proceso es bastante simple: cuando el cliente predice el movimiento, guarda un búfer de estado (posición y rotación) y una entrada. Después de recibir un mensaje de estado del servidor, compara el estado recibido con el estado predicho del búfer. Si difieren en un valor demasiado grande, redefinimos el estado del cubo del cliente en el pasado y luego simulamos nuevamente todas las medidas intermedias.

 // client private ClientState[] client_state_buffer = new ClientState[1024]; private Inputs[] client_input_buffer = new Inputs[1024]; private void Update() { this.timer += Time.deltaTime; while (this.timer >= Time.fixedDeltaTime) { this.timer -= Time.fixedDeltaTime; Inputs inputs = this.SampleInputs(); InputMessage input_msg; input_msg.inputs = inputs; input_msg.tick_number = this.tick_number; this.SendToServer(input_msg); uint buffer_slot = this.tick_number % 1024; this.client_input_buffer[buffer_slot] = inputs; this.client_state_buffer[buffer_slot].position = rigidbody.position; this.client_state_buffer[buffer_slot].rotation = rigidbody.rotation; this.AddForcesToPlayer(player.GetComponent<Rigidbody>(), inputs); Physics.Simulate(Time.fixedDeltaTime); ++this.tick_number; } while (this.HasAvailableStateMessage()) { StateMessage state_msg = this.GetStateMessage(); uint buffer_slot = state_msg.tick_number % c_client_buffer_size; Vector3 position_error = state_msg.position - this.client_state_buffer[buffer_slot].position; if (position_error.sqrMagnitude > 0.0000001f) { // rewind & replay Rigidbody player_rigidbody = player.GetComponent<Rigidbody>(); player_rigidbody.position = state_msg.position; player_rigidbody.rotation = state_msg.rotation; player_rigidbody.velocity = state_msg.velocity; player_rigidbody.angularVelocity = state_msg.angular_velocity; uint rewind_tick_number = state_msg.tick_number; while (rewind_tick_number < this.tick_number) { buffer_slot = rewind_tick_number % c_client_buffer_size; this.client_input_buffer[buffer_slot] = inputs; this.client_state_buffer[buffer_slot].position = player_rigidbody.position; this.client_state_buffer[buffer_slot].rotation = player_rigidbody.rotation; this.AddForcesToPlayer(player_rigidbody, inputs); Physics.Simulate(Time.fixedDeltaTime); ++rewind_tick_number; } } } } 

Los datos de entrada y estado almacenados en el búfer se almacenan en un búfer circular muy simple, donde el identificador de medida se usa como índice. Y elegí el valor de 64 Hz para la frecuencia de reloj de la física, es decir, un búfer de 1024 elementos nos da espacio durante 16 segundos, y esto es mucho más de lo que podemos necesitar.


La corrección está en marcha!

Transferencia de entrada redundante


Los mensajes de entrada suelen ser muy pequeños: los botones presionados se pueden combinar en un campo de bits que solo ocupa unos pocos bytes. Todavía hay un número de medida en nuestro mensaje, que ocupa 4 bytes, pero podemos comprimirlos fácilmente usando un valor de 8 bits con un acarreo (tal vez el intervalo 0-255 será demasiado pequeño, podemos estar seguros y aumentarlo a 9 o 10 bits). Sea como fuere, estos mensajes son bastante pequeños, y esto significa que podemos enviar muchos datos de entrada en cada mensaje (en caso de que se hayan perdido los datos de entrada anteriores). ¿Qué tan atrás deberíamos regresar? Bueno, el cliente conoce el número de medida del último mensaje de estado que recibió del servidor, por lo que no tiene sentido volver más allá de esta medida. También necesitamos imponer un límite en la cantidad de datos de entrada redundantes enviados por el cliente. No hice esto en mi demo, pero debería implementarse en el código terminado.

 while (this.HasAvailableStateMessage()) { StateMessage state_msg = this.GetStateMessage(); this.client_last_received_state_tick = state_msg.tick_number; 

Este es un cambio simple, el cliente simplemente escribe el número de medida del último mensaje de estado recibido.

 Inputs inputs = this.SampleInputs(); InputMessage input_msg; input_msg.start_tick_number = this.client_last_received_state_tick; input_msg.inputs = new List<Inputs>(); for (uint tick = this.client_last_received_state_tick; tick <= this.tick_number; ++tick) { input_msg.inputs.Add(this.client_input_buffer[tick % 1024]); } this.SendToServer(input_msg); 

El mensaje de entrada enviado por el cliente ahora contiene una lista de datos de entrada, no solo un elemento. La parte con el número de medida obtiene un nuevo valor; ahora es el número de medida de la primera entrada de esta lista.

 while (this.HasAvailableInputMessages()) { InputMessage input_msg = this.GetInputMessage(); // message contains an array of inputs, calculate what tick the final one is uint max_tick = input_msg.start_tick_number + (uint)input_msg.inputs.Count - 1; // if that tick is greater than or equal to the current tick we're on, then it // has inputs which are new if (max_tick >= server_tick_number) { // there may be some inputs in the array that we've already had, // so figure out where to start uint start_i = server_tick_number > input_msg.start_tick_number ? (server_tick_number - input_msg.start_tick_number) : 0; // run through all relevant inputs, and step player forward Rigidbody rigidbody = player.GetComponent<Rigidbody>(); for (int i = (int)start_i; i < input_msg.inputs.Count; ++i) { this.AddForcesToPlayer(rigidbody, input_msg.inputs[i]); Physics.Simulate(Time.fixedDeltaTime); } server_tick_number = max_tick + 1; } } 

Cuando el servidor recibe un mensaje de entrada, conoce el número de medida de la primera entrada y la cantidad de datos de entrada en el mensaje. Por lo tanto, puede calcular la medida de la última entrada en el mensaje. Si esta última medida es mayor o igual que el número de medida del servidor, entonces sabe que el mensaje contiene al menos una entrada que el servidor aún no ha visto. Si es así, simula todos los datos de entrada nuevos.

Es posible que haya notado que si limitamos la cantidad de datos de entrada redundantes en el mensaje de entrada, con un número suficientemente grande de mensajes de entrada perdidos, tendremos una brecha de simulación entre el servidor y el cliente. Es decir, el servidor puede simular la medida 100, enviar un mensaje de estado para iniciar la medida 101 y luego recibir un mensaje de entrada que comienza en la medida 105. En el código anterior, el servidor irá a 105, no intentará simular medidas intermedias basadas en los últimos datos de entrada conocidos. Si lo necesitas depende de tu decisión y de cuál debería ser el juego. Personalmente, no forzaría al servidor a especular y mover al jugador en el mapa debido al mal estado de la red. Creo que es mejor dejar el reproductor en su lugar hasta que se restablezca la conexión.

En la demostración "Zen of Networked Physics" hay una función para enviar "movimientos importantes" por parte del cliente, es decir, envía datos de entrada redundantes solo cuando difiere de la entrada transmitida anteriormente. Esto se puede llamar compresión delta de entrada, y con él puede reducir aún más el tamaño de los mensajes de entrada. Pero hasta ahora no lo he hecho, porque en esta demostración no hay optimización de la carga de la red.


Antes de enviar datos de entrada redundantes: cuando se pierde el 25% de los paquetes, el movimiento del cubo es lento y se contrae, continúa siendo devuelto.


Después de enviar datos de entrada redundantes: con una pérdida del 25% de los paquetes, todavía hay una corrección de contracción, pero los cubos se mueven a una velocidad aceptable.

Frecuencia variable de instantáneas


En esta demostración, la frecuencia con la que el servidor envía instantáneas al cliente varía. Con una frecuencia reducida, el cliente necesitará más tiempo para recibir la corrección del servidor. Por lo tanto, cuando el cliente se equivoca en el pronóstico, antes de recibir un mensaje de estado puede desviarse aún más, lo que conducirá a una corrección más notable. Con una alta frecuencia de instantáneas, la pérdida de paquetes es mucho menos importante, por lo que el cliente no tiene que esperar mucho para recibir la próxima instantánea.


Frecuencia de instantánea 64 Hz


Frecuencia de instantánea 16 Hz


Frecuencia de instantánea 2 Hz

Obviamente, cuanto mayor sea la frecuencia de las instantáneas, mejor, por lo que debe enviarlas con la mayor frecuencia posible. Pero también depende de la cantidad de tráfico adicional, su costo, la disponibilidad de servidores dedicados, los costos informáticos de los servidores, etc.

Corrección de suavizado


Creamos pronósticos incorrectos y obtenemos correcciones desiguales con más frecuencia de la que nos gustaría. Sin el acceso adecuado a la integración de Unity / PhysX, apenas puedo depurar estos pronósticos erróneos. He dicho esto antes, pero repito una vez más: si encuentra algo relacionado con la física, en lo que estoy equivocado, hágamelo saber.

¡Eludí la solución a este problema al pasar por alto las grietas con un buen alisado! Cuando se produce una corrección, el cliente simplemente suaviza la posición y la rotación del jugador en la dirección del estado correcto durante varios fotogramas. El cubo físico en sí mismo se corrige instantáneamente (es invisible), pero tenemos un segundo cubo solo para visualización, que permite suavizar.

 Vector3 position_error = state_msg.position - predicted_state.position; float rotation_error = 1.f - Quaternion.Dot(state_msg.rotation, predicted_state.rotation); if (position_error.sqrMagnitude > 0.0000001f || rotation_error > 0.00001f) { Rigidbody player_rigidbody = player.GetComponent<Rigidbody>(); // capture the current predicted pos for smoothing Vector3 prev_pos = player_rigidbody.position + this.client_pos_error; Quaternion prev_rot = player_rigidbody.rotation * this.client_rot_error; // rewind & replay player_rigidbody.position = state_msg.position; player_rigidbody.rotation = state_msg.rotation; player_rigidbody.velocity = state_msg.velocity; player_rigidbody.angularVelocity = state_msg.angular_velocity; uint rewind_tick_number = state_msg.tick_number; while (rewind_tick_number < this.tick_number) { buffer_slot = rewind_tick_number % c_client_buffer_size; this.client_input_buffer[buffer_slot] = inputs; this.client_state_buffer[buffer_slot].position = player_rigidbody.position; this.client_state_buffer[buffer_slot].rotation = player_rigidbody.rotation; this.AddForcesToPlayer(player_rigidbody, inputs); Physics.Simulate(Time.fixedDeltaTime); ++rewind_tick_number; } // if more than 2ms apart, just snap if ((prev_pos - player_rigidbody.position).sqrMagnitude >= 4.0f) { this.client_pos_error = Vector3.zero; this.client_rot_error = Quaternion.identity; } else { this.client_pos_error = prev_pos - player_rigidbody.position; this.client_rot_error = Quaternion.Inverse(player_rigidbody.rotation) * prev_rot; } } 

Cuando ocurre un pronóstico erróneo, el cliente rastrea la diferencia de posición / rotación después de la corrección. Si la distancia total de corrección de posición es más de 2 metros, entonces el cubo simplemente se mueve bruscamente; el alisado aún se vería mal, así que al menos vuelva al estado correcto lo más rápido posible.

 this.client_pos_error *= 0.9f; this.client_rot_error = Quaternion.Slerp(this.client_rot_error, Quaternion.identity, 0.1f); this.smoothed_client_player.transform.position = player_rigidbody.position + this.client_pos_error; this.smoothed_client_player.transform.rotation = player_rigidbody.rotation * this.client_rot_error; 

En cada cuadro, el cliente realiza lerp / slerp hacia la posición / rotación correcta en un 10%, este es un enfoque estándar de ley de potencia para promediar el movimiento. Depende de la velocidad de fotogramas, pero para los fines de nuestra demostración esto es suficiente.


250 ms de retraso
Perdió el 10% de los paquetes
Sin suavizado, la corrección es muy notable.


250 ms de retraso
Perdió el 10% de los paquetes
Con el suavizado, la corrección es mucho más difícil de notar.

El resultado final funciona bastante bien, quiero crear una versión que realmente envíe paquetes, en lugar de imitarlos. Pero al menos esto es una prueba de concepto para un sistema de pronóstico del lado del cliente con objetos físicos reales en Unity sin la necesidad de complementos físicos y similares.

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


All Articles