¿Cómo manejar netcode?

10

Estoy interesado en evaluar las diferentes formas en que el netcode puede "engancharse" a un motor de juego. Estoy diseñando un juego multijugador ahora, y hasta ahora he determinado que necesito (al menos) tener un hilo separado para manejar los zócalos de red, distinto del resto del motor que maneja el bucle de gráficos y las secuencias de comandos.

Tenía una manera potencial de hacer que un juego en red sea de un solo subproceso, que es verificar la red después de renderizar cada cuadro, utilizando sockets sin bloqueo. Sin embargo, esto claramente no es óptimo porque el tiempo que lleva renderizar un cuadro se agrega al retraso de la red: los mensajes que llegan a través de la red deben esperar hasta que se complete el procesamiento actual del cuadro (y la lógica del juego). Pero, al menos de esta manera, la jugabilidad seguiría siendo fluida, más o menos.

Tener un hilo separado para la conexión en red permite que el juego responda completamente a la red, por ejemplo, puede enviar un paquete ACK instantáneamente al recibir una actualización de estado del servidor. Pero estoy un poco confundido acerca de la mejor manera de comunicarse entre el código del juego y el código de red. El hilo de red empujará el paquete recibido a una cola, y el hilo del juego leerá de la cola en el momento apropiado durante su ciclo, por lo que no nos hemos librado de este retraso de hasta un cuadro.

Además, parece que me gustaría que el subproceso que maneja el envío de paquetes esté separado del que está buscando paquetes que salen por la tubería, porque no podría enviar uno mientras está en el medio de comprobando si hay mensajes entrantes. Estoy pensando en la funcionalidad de selecto similar.

Supongo que mi pregunta es, ¿cuál es la mejor manera de diseñar el juego para la mejor capacidad de respuesta de la red? Claramente, el cliente debe enviar la entrada del usuario lo antes posible al servidor, para que pueda recibir el código de envío de red inmediatamente después del ciclo de procesamiento de eventos, ambos dentro del ciclo del juego. ¿Tiene esto algún sentido?

Steven Lu
fuente

Respuestas:

13

Ignorar la capacidad de respuesta. En una LAN, el ping es insignificante. En internet, 60-100ms ida y vuelta es una bendición. Ora a los dioses rezagados para que no obtengas picos de> 3K. Su software tendría que ejecutarse con un número muy bajo de actualizaciones / segundo para que esto sea un problema. Si disparas durante 25 actualizaciones / segundo, entonces tienes un tiempo máximo de 40 ms entre cuando recibes un paquete y actúas sobre él. Y ese es el caso de un solo hilo ...

Diseñe su sistema para que sea flexible y correcto. Aquí está mi idea sobre cómo conectar un subsistema de red al código del juego: Mensajería. La solución a muchos problemas puede ser la "mensajería". Creo que los mensajes curaron el cáncer en ratas de laboratorio una vez. Los mensajes me ahorran $ 200 o más en mi seguro de automóvil. Pero en serio, la mensajería es probablemente la mejor manera de adjuntar cualquier subsistema al código del juego mientras se mantienen dos subsistemas independientes.

Utilice la mensajería para cualquier comunicación entre el subsistema de red y el motor del juego, y para el caso entre dos subsistemas. La mensajería entre subsistemas puede ser tan simple como un conjunto de datos pasados ​​por puntero usando std :: list.

Simplemente tenga una cola de mensajes salientes y una referencia al motor del juego en el subsistema de red. El juego puede volcar los mensajes que desea enviar a la cola saliente y enviarlos automáticamente, o tal vez cuando se llama a alguna función como "flushMessages ()". Si el motor del juego tenía una cola de mensajes grande y compartida, entonces todos los subsistemas que necesitaban enviar mensajes (lógica, IA, física, red, etc.) pueden volcar todos los mensajes en él, donde el bucle principal del juego puede leer todos los mensajes y luego actuar en consecuencia.

Diría que ejecutar los sockets en otro hilo está bien, aunque no es obligatorio. El único problema con este diseño es que generalmente es asíncrono (no se sabe exactamente cuándo se envían los paquetes) y eso puede dificultar la depuración y hacer que los problemas relacionados con el tiempo aparezcan / desaparezcan al azar. Aún así, si se hace correctamente, ninguno de estos debería ser un problema.

Desde un nivel superior, diría que la red separada del motor del juego en sí. El motor del juego no se preocupa por los enchufes o los buffers, se preocupa por los eventos. Los eventos son cosas como "El jugador X disparó" "Una explosión en el juego T sucedió". Estos pueden ser interpretados por el motor del juego directamente. De dónde se generan (un script, la acción de un cliente, un jugador de IA, etc.) no importa.

Si trata su subsistema de red como un medio para enviar / recibir eventos, entonces obtiene una serie de ventajas sobre solo llamar a recv () en un socket.

Podría optimizar el ancho de banda, por ejemplo, tomando 50 mensajes pequeños (1-32 bytes de longitud) y haciendo que el subsistema de red los empaquete en un paquete grande y lo envíe. Tal vez podría comprimirlos antes de enviarlos si eso fuera un gran problema. En el otro extremo, el código podría descomprimir / desempaquetar el paquete grande en 50 eventos discretos nuevamente para que lo lea el motor del juego. Todo esto puede suceder de manera transparente.

Otras cosas interesantes incluyen el modo de juego para un solo jugador que reutiliza su código de red al tener un cliente puro + un servidor puro que se ejecuta en la misma máquina que se comunica a través de mensajes en el espacio de memoria compartida. Luego, si su juego para un solo jugador funciona correctamente, también funcionaría un cliente remoto (es decir, multijugador verdadero). Además, te obliga a considerar con anticipación qué datos necesita el cliente, ya que tu juego para un solo jugador se vería bien o estaría totalmente equivocado. Mezcla y combina, ejecuta un servidor Y sé un cliente en un juego multijugador: todo funciona igual de fácil.

PatrickB
fuente
Usted mencionó el uso de una simple std :: list o alguna para pasar mensajes. Este puede ser un tema para StackOverflow, pero ¿es cierto que todos los subprocesos comparten el mismo espacio de direcciones, y siempre que evite que varios subprocesos se atornillen con la memoria que pertenece a mi cola simultáneamente, debería estar bien? ¿Puedo asignar datos para la cola en el montón como de costumbre y simplemente usar algunos mutexes?
Steven Lu
Si eso es correcto. Un mutex que protege todas las llamadas a std :: list.
PatrickB
¡Gracias por responder! He estado progresando mucho con mis rutinas de enhebrado hasta ahora. ¡Es una gran sensación tener mi propio motor de juego!
Steven Lu
44
Ese sentimiento desaparecerá. Sin embargo, los grandes de bronce que obtienes, permanecen contigo.
ChrisE
@StevenLu Algo [extremadamente] tarde, pero quiero señalar que evitar que los hilos se atornillen con la memoria simultáneamente puede ser extremadamente difícil, dependiendo de cómo intentes hacerlo y cuán eficiente quieras ser. Si estuviera haciendo esto hoy, le señalaría una de las muchas implementaciones excelentes de código abierto de colas concurrentes, por lo que no necesita reinventar una rueda complicada.
Financia la demanda de Mónica el
4

Necesito (al menos) tener un hilo separado para manejar los sockets de red

No, no lo haces.

Tenía una manera potencial de hacer que un juego en red sea de un solo subproceso, que es verificar la red después de renderizar cada cuadro, utilizando sockets sin bloqueo. Sin embargo, esto claramente no es óptimo porque el tiempo que lleva renderizar un marco se agrega al retraso de la red:

No necesariamente importa. ¿Cuándo se actualiza tu lógica? No tiene sentido sacar datos de la red si aún no puede hacer nada con ella. Del mismo modo, no tiene mucho sentido responder si aún no tiene nada que decir.

por ejemplo, puede enviar un paquete ACK instantáneamente al recibir una actualización de estado del servidor.

Si su juego es tan rápido que esperar el siguiente cuadro será un retraso significativo, entonces enviará suficientes datos para que no necesite enviar paquetes ACK separados, solo incluya valores ACK dentro de sus datos normales cargas útiles, si es que las necesita.

Para la mayoría de los juegos en red, es perfectamente posible tener un bucle de juego como este:

while 1:
    read_network_messages()
    read_local_input()
    update_world()
    send_network_updates()
    render_world()

Puede desacoplar las actualizaciones del renderizado, lo cual es muy recomendable, pero todo lo demás puede mantenerse así de simple a menos que tenga una necesidad específica. ¿Qué tipo de juego estás haciendo exactamente?

Kylotan
fuente
77
Desacoplar la actualización de la representación no solo es muy recomendable, es necesario si desea un motor que no sea de mierda.
AttackingHobo
Sin embargo, la mayoría de las personas no están haciendo motores, y probablemente la mayoría de los juegos todavía no desacoplan los dos. La solución para pasar un valor de tiempo transcurrido a la función de actualización funciona aceptablemente en la mayoría de los casos.
Kylotan
2

Sin embargo, esto claramente no es óptimo porque el tiempo que lleva procesar un cuadro se agrega al retraso de la red: los mensajes que llegan a través de la red deben esperar hasta que se complete el cuadro actual (y la lógica del juego).

Eso no es cierto en absoluto. El mensaje pasa por la red mientras el receptor representa la trama actual. El retraso de la red está sujeto a un número entero de tramas en el lado del cliente; sí, pero si el cliente tiene tan pocos FPS que esto es un gran problema, entonces tienen mayores problemas.

DeadMG
fuente
0

Las comunicaciones de red deben estar agrupadas. Debes esforzarte por obtener un solo paquete enviado cada tick del juego (que a menudo es cuando se procesa un marco, pero realmente debería ser independiente).

Sus entidades de juego hablan con el subsistema de red (NSS). El NSS agrupa mensajes, ACK, etc. y envía algunos (con suerte, uno) paquetes UDP de tamaño óptimo (por lo general, ~ 1500 bytes). El NSS emula paquetes, canales, prioridades, reenvío, etc., mientras que solo envía paquetes UDP únicos.

Lea el tutorial del gaffer en los juegos o simplemente use ENet que implementa muchas de las ideas de Glenn Fiedler.

O simplemente puede usar TCP si su juego no necesita reacciones de contracción. Luego, todos los problemas de lotes, reenvíos y ACK desaparecen. Sin embargo, aún querría un NSS para administrar el ancho de banda y los canales.

código_deft
fuente
0

No ignores completamente la capacidad de respuesta. Hay poco que ganar al agregar otra latencia de 40 ms a los paquetes ya retrasados. Si está agregando un par de cuadros (a 60 fps), está retrasando el procesamiento de una actualización de posición de otro par de cuadros. Es mejor aceptar paquetes rápidamente y procesarlos rápidamente para mejorar la precisión de la simulación.

He tenido un gran éxito al optimizar el ancho de banda al pensar en la información de estado mínima necesaria para representar lo que es visible en la pantalla. Luego observa cada bit de datos y elige un modelo para ello. La información de posición se puede expresar como valores delta a lo largo del tiempo. Puede usar sus propios modelos estadísticos para esto y pasar años depurándolos, o puede usar una biblioteca para ayudarlo. Prefiero usar el modelo de punto flotante de esta biblioteca DataBlock_Predict_Float. Esto hace que sea realmente fácil optimizar el ancho de banda utilizado para el gráfico de la escena del juego.

Justin
fuente