Sincronización entre el hilo lógico del juego y el hilo de renderizado

16

¿Cómo se separa la lógica y la representación del juego? Sé que parece que ya hay preguntas aquí que preguntan exactamente eso, pero las respuestas no me satisfacen.

Por lo que entiendo hasta ahora, el punto de separarlos en diferentes subprocesos es para que la lógica del juego pueda comenzar a ejecutarse para el siguiente tic inmediatamente, en lugar de esperar al próximo vsync, donde el renderizado finalmente regresa de la llamada swapbuffer en la que se ha bloqueado.

Pero específicamente qué estructuras de datos se utilizan para evitar condiciones de carrera entre el hilo lógico del juego y el hilo de renderizado. Presumiblemente, el hilo de renderizado necesita acceso a varias variables para descubrir qué dibujar, pero la lógica del juego podría estar actualizando estas mismas variables.

¿Existe una técnica estándar de facto para manejar este problema? Tal vez, como copiar los datos que necesita el hilo de renderizado después de cada ejecución de la lógica del juego. Cualquiera sea la solución, ¿será la sobrecarga de la sincronización o lo que sea menor que simplemente ejecutar todo con un solo subproceso?

usuario782220
fuente
1
Odio enviar spam a un enlace, pero creo que esta es una muy buena lectura y debería responder a todas sus preguntas: altdevblogaday.com/2011/07/03/threading-and-your-game-loop
Roy T.
Otro enlace: software.intel.com/en-us/articles/…
Chewy Gumball
1
Esos enlaces dan el resultado final típico que a uno le gustaría, pero no explican cómo hacerlo. ¿Copiarías el gráfico de la escena completa de cada cuadro o algo más? Las discusiones son de alto nivel y vagas.
user782220
Pensé que los enlaces eran bastante explícitos sobre cuánto estado se copiaba en cada caso. p.ej. (desde el primer enlace) "Un lote contiene toda la información necesaria para dibujar un marco, pero no contiene ningún otro estado del juego". o (desde el segundo enlace) "Sin embargo, los datos aún deben compartirse, pero ahora en lugar de que cada sistema acceda a una ubicación de datos común para decir, obtener datos de posición u orientación, cada sistema tiene su propia copia" (Ver especialmente 3.2.2 - Estado Manager)
DMGregory
Quien escribió ese artículo de Intel no parece saber que el subproceso de alto nivel es una muy mala idea. Nadie hace algo tan estúpido. De repente, toda la aplicación tiene que comunicarse a través de canales especializados y hay bloqueos y / o grandes intercambios estatales coordinados en todas partes. Sin mencionar que no se sabe cuándo se procesarán los datos enviados, por lo que es extremadamente difícil razonar sobre lo que hace el código. Es mucho más fácil copiar los datos relevantes de la escena (inmutables como punteros contados por referencia, mutables por valor) en un punto y dejar que el subsistema los resuelva como quiera.
serpiente5

Respuestas:

1

He estado trabajando en lo mismo. La preocupación adicional es que OpenGL (y que yo sepa, OpenAL), y una serie de otras interfaces de hardware, son efectivamente máquinas de estado que no se llevan bien con la llamada de múltiples subprocesos. No creo que su comportamiento esté definido, y para LWJGL (posiblemente también JOGL) a menudo arroja una excepción.

Lo que terminé haciendo fue crear una secuencia de hilos que implementaban una interfaz específica y cargarlos en la pila de un objeto de control. Cuando ese objeto recibe una señal para cerrar el juego, se ejecutará a través de cada hilo, llamará a un método implementado ceaseOperations () y esperará a que se cierre antes de cerrarse. Los datos universales que podrían ser relevantes para reproducir sonido, gráficos o cualquier otro dato se mantienen en una secuencia de objetos que son volátiles o están disponibles universalmente para todos los hilos, pero nunca se guardan en la memoria de hilos. Hay una leve penalización de rendimiento allí, pero si se usa correctamente, me ha permitido asignar flexiblemente audio a un hilo, gráficos a otro, física a otro, y así sucesivamente sin vincularlos al tradicional (y temido) "bucle de juego".

Por lo tanto, por regla general, todas las llamadas de OpenGL pasan por el subproceso de gráficos, todas las OpenAL a través del subproceso de audio, todas las entradas a través del subproceso de entrada y todo lo que el subproceso de control de organización debe preocuparse es la gestión de subprocesos. El estado del juego se lleva a cabo en la clase GameState, que todos pueden ver cuando lo necesiten. Si alguna vez decido que, por ejemplo, JOAL se ha puesto al día y quiero usar la nueva edición de JavaSound, solo implemento un hilo diferente para Audio.

Espero que vean lo que digo, ya tengo algunos miles de líneas en este proyecto. Si desea que intente reunir una muestra, veré qué puedo hacer.

Michael Oberlin
fuente
El problema que eventualmente enfrentará es que esta configuración no escala particularmente bien en una máquina multi-núcleo. Sí, hay aspectos de un juego que generalmente se sirven mejor en su propio hilo, como el audio, pero gran parte del resto del bucle del juego se puede administrar en serie junto con las tareas del grupo de hilos. Si su grupo de subprocesos admite máscaras de afinidad, puede poner en cola fácilmente las tareas de procesamiento que se ejecutarán en el mismo subproceso y hacer que su planificador de subprocesos administre las colas de trabajo de subprocesos y haga el trabajo de robo según sea necesario para brindarle soporte de subprocesos múltiples y múltiples núcleos.
Naros
1

Por lo general, la lógica que se ocupa de los pases de representación gráfica (y su programación, y cuándo se ejecutarán, etc.) se maneja en un hilo separado. Sin embargo, ese hilo ya está implementado (en funcionamiento) por la plataforma que utiliza para desarrollar su ciclo de juego (y juego).

Entonces, para obtener un bucle de juego donde la lógica del juego se actualiza independientemente del programa de actualización de gráficos, no necesita hacer hilos adicionales, simplemente toque el hilo ya existente para dichas actualizaciones de gráficos.

Esto depende de qué plataforma estés usando. Por ejemplo:

  • Si lo está haciendo en la mayoría de las plataformas relacionadas con Open GL ( GLUT para C / C ++ , JOLG para Java , Acción relacionada con OpenGL ES de Android ), generalmente le proporcionarán un método / función que periódicamente se llama por el hilo de renderizado, y que usted puede integrarse en su bucle de juego (sin hacer que las iteraciones del gameloop dependan de cuándo se llama a ese método). Para GLUT usando C, haces algo como esto:

    glutDisplayFunc (myFunctionForGraphicsDrawing);

    glutIdleFunc (myFunctionForUpdatingState);

  • en JavaScript, puedes usar Web Workers ya que no hay subprocesos múltiples (que puede alcanzar programáticamente) , también puede usar el mecanismo "requestAnimationFrame" para recibir notificaciones cuando se programe una nueva representación gráfica y hacer las actualizaciones de estado del juego en consecuencia .

Básicamente, lo que quieres es un ciclo de juego de pasos mixtos: tienes un código que actualiza el estado del juego, y que se llama dentro del hilo principal de tu juego, y también quieres acceder periódicamente (o ser llamado por) Subproceso de representación de gráficos existente para avisar cuándo es el momento de actualizar los gráficos.

Shivan Dragon
fuente
0

En Java existe la palabra clave "sincronizada", que bloquea las variables que le pasa para que sean seguras. En C ++ puede lograr lo mismo con Mutex. P.ej:

Java:

synchronized(a){
    //code using a
}

C ++:

mutex a_mutex;

void f(){
    a_mutex.lock();
    //code using a
    a_mutex.unlock();
}

El bloqueo de variables asegura que no cambien mientras se ejecuta el código que lo sigue, por lo que las variables no cambian por su hilo de actualización mientras las procesa (de hecho, cambian, pero desde el punto de vista de su hilo de representación no lo hacen ' t). Sin embargo, debe tener cuidado con la palabra clave sincronizada en Java, ya que solo se asegura de que el puntero a la variable / objeto no cambie. Los atributos aún pueden cambiar sin cambiar el puntero. Para contemplar esto, puede copiar el objeto usted mismo o llamar sincronizado en todos los atributos del objeto que no desea cambiar.

zedutchgandalf
fuente
1
Los mutexes no son necesariamente la respuesta aquí porque el OP no solo necesitaría desacoplar la lógica y el renderizado del juego, sino que también quieren evitar cualquier estancamiento de la capacidad de un hilo para avanzar en su procesamiento, independientemente de dónde esté el otro hilo actualmente en proceso. lazo.
Naros
0

Lo que generalmente he visto para manejar la comunicación de hilo lógico / render es triplicar sus datos. De esta manera, el subproceso de representación tiene el depósito 0 del que lee. El hilo lógico utiliza el depósito 1 como fuente de entrada para el siguiente cuadro y escribe los datos del cuadro en el depósito 2.

En los puntos de sincronización, los índices de lo que significa cada uno de los tres cubos se intercambian para que los datos del siguiente cuadro se entreguen al hilo de renderizado y el hilo lógico pueda continuar hacia adelante.

Pero no hay necesariamente una razón para dividir el renderizado y la lógica en sus respectivos hilos. De hecho, puede mantener el ciclo del juego en serie y desacoplar su velocidad de cuadros de renderización del paso lógico utilizando la interpolación. Para aprovechar los procesadores multinúcleo que utilizan este tipo de configuración es donde tendría un grupo de subprocesos que funciona en grupos de tareas. Estas tareas pueden ser simplemente cosas como, en lugar de iterar una lista de objetos de 0 a 100, iterar la lista en 5 cubos de 20 en 5 hilos aumentando efectivamente su rendimiento pero sin complicar demasiado el ciclo principal.

Naros
fuente
0

Esta es una publicación antigua pero aún aparece, así que quería agregar mis 2 centavos aquí.

Primero, enumere los datos que deberían almacenarse en la interfaz de usuario / subproceso de visualización frente al subproceso lógico. En el subproceso de la interfaz de usuario, puede incluir malla 3D, texturas, información de luz y una copia de los datos de posición / rotación / dirección.

En el hilo de la lógica del juego, es posible que necesite el tamaño del objeto del juego en 3D, primitivas delimitadores (esfera, cubo), datos de malla 3D simplificados (para colisiones detalladas, por ejemplo), todos los atributos que afectan el movimiento / comportamiento, como la velocidad del objeto, la relación de giro, etc. y también datos de posición / rotación / dirección.

Si compara dos listas, puede ver que solo se necesita pasar una copia de los datos de posición / rotación / dirección de la lógica al hilo de la interfaz de usuario. Es posible que también necesite algún tipo de identificación de correlación para determinar a qué objeto del juego pertenecen estos datos.

Cómo lo hagas depende del idioma con el que estés trabajando. En Scala puede usar la memoria transaccional de software, en Java / C ++ algún tipo de bloqueo / sincronización. Me gustan los datos inmutables, por lo que tiendo a devolver un nuevo objeto inmutable para cada actualización. Esto es un poco de pérdida de memoria, pero con las computadoras modernas no es tan importante. Aún así, si desea bloquear las estructuras de datos compartidos, puede hacerlo. Echa un vistazo a la clase Exchanger en Java, usar dos o más buffers puede acelerar las cosas.

Antes de comenzar a compartir datos entre subprocesos, calcule cuántos datos realmente necesita pasar. Si tiene un octree que divide su espacio 3d, y puede ver 5 objetos del juego de un total de 10 objetos, incluso si su lógica necesita actualizar los 10, debe volver a dibujar solo los 5 que está viendo. Para más información, visite este blog: http://gameprogrammingpatterns.com/game-loop.html Esto no se trata de sincronización, pero muestra cómo la lógica del juego se separa de la pantalla y qué desafíos necesita superar (FPS). Espero que esto ayude,

marca

Mark Citizen
fuente