Volver a Procesos e Hilos Bloque 9 - Procesos frente a hilos en la práctica - Tema 40 de 43

Tema 40

Videojuegos

Los videojuegos son aplicaciones de tiempo real que deben procesar entrada, actualizar la simulación y renderizar gráficos a 60 FPS (o más). Esto requiere aprovechar todos los núcleos de la CPU. En este tema verás el bucle principal, los hilos de dibujo, audio y física, y los sistemas de tareas que distribuyen el trabajo.

1. El problema de los videojuegos

Un videojuego es una aplicación de tiempo real que debe procesar la entrada del usuario, actualizar la simulación (física, IA, lógica de juego) y renderizar los gráficos, todo a una tasa constante (típicamente 60 FPS, es decir, 16.67 ms por frame). Si un frame tarda más de 16.67 ms, el juego se ve "lento" o "entrecortado".

El problema es que un solo núcleo de CPU no puede hacer todo esto en 16.67 ms. Un juego moderno tiene:

Para cumplir con el límite de tiempo, los videojuegos usan concurrencia: hilos para las tareas que se pueden paralelizar, y sistemas de tareas para distribuir el trabajo entre los núcleos.

Los videojuegos son el caso extremo de concurrencia

Los videojuegos son aplicaciones de tiempo real con restricciones estrictas de latencia. Cada frame debe completarse en 16.67 ms (60 FPS). Esto hace que la concurrencia sea crítica: si un hilo bloquea otro, el frame se retrasa y el juego se ve mal.

2. El bucle principal (game loop)

El bucle principal (game loop) es el corazón del videojuego. Es un bucle infinito que se ejecuta mientras el juego está en marcha. En cada iteración (frame), el bucle hace:

  1. Procesar entrada: leer el teclado, ratón, mando. Actualizar el estado de los botones.
  2. Actualizar la simulación: física, IA, lógica de juego. Avanzar el tiempo de juego.
  3. Renderizar: dibujar la escena en la pantalla.
  4. Sincronizar: esperar al vsync (sincronización vertical) para evitar tearing.

El bucle principal se ejecuta en el hilo principal. Las tareas pesadas (física, renderizado) se delegan a hilos separados.

C - game loop básico

while (running) {
    /* 1. Procesar entrada */
    process_input();

    /* 2. Actualizar simulación */
    update_physics(dt);
    update_ai(dt);
    update_game_logic(dt);

    /* 3. Renderizar */
    render();

    /* 4. Sincronizar (vsync) */
    wait_for_vsync();
}

El dt (delta time) es el tiempo transcurrido desde el frame anterior. Se usa para que la simulación avance a la misma velocidad independientemente del framerate.

Hay dos variantes del game loop:

3. Los sistemas del juego

Un juego se divide en sistemas, cada uno responsable de una parte de la simulación:

SistemaFunciónFrecuencia
InputLeer teclado, ratón, mando.Cada frame
PhysicsSimular movimiento, colisiones, gravedad.Cada frame (o fixed timestep)
AIDecisiones de enemigos, pathfinding.Cada N frames
AudioMezclar sonidos, aplicar efectos.Cada frame
RenderDibujar la escena en la pantalla.Cada frame
NetworkEnviar y recibir datos en multijugador.Cada frame
Game LogicLógica del juego (puntuación, estado, etc.).Cada frame

Cada sistema es independiente en cierta medida, lo que permite paralelizarlos. Pero tienen dependencias: la física necesita la entrada, el renderizado necesita la física, etc.

4. Hilos de dibujo (render)

El hilo de renderizado (render thread) es el encargado de dibujar la escena. En un juego moderno, el renderizado se divide en varias etapas:

  1. Culling: determinar qué objetos son visibles (no están fuera de la cámara).
  2. Sorting: ordenar los objetos por distancia (para transparencias) y por material (para minimizar cambios de estado).
  3. Draw calls: enviar comandos a la GPU para dibujar cada objeto.
  4. Post-processing: aplicar efectos (bloom, HDR, etc.).
  5. Present: enviar la imagen final a la pantalla (vsync).

El renderizado es altamente paralelizable: el culling, el sorting y los draw calls se pueden distribuir entre múltiples hilos. Pero el main thread (o el render thread) debe coordinar el resultado.

En la mayoría de los motores de juego, el render thread es un hilo separado del main thread. El main thread prepara la lista de objetos a renderizar, y el render thread los dibuja. Esto permite que el main thread avance a la siguiente frame mientras el render thread termina de dibujar.

El render thread es el hilo más importante

El render thread es el que determina el framerate. Si el renderizado tarda más de 16.67 ms, el juego se ve lento. Por eso, los motores de juego optimizan al máximo el render thread: culling agresivo, batching de draw calls, y paralelización con hilos raster.

5. Hilos de física

El hilo de física (physics thread) simula el movimiento de los objetos, las colisiones y las fuerzas. Es una de las tareas más costosas del juego.

La física se puede paralelizar de varias formas:

La física es difícil de paralelizar porque tiene dependencias: la posición de un objeto depende de las colisiones con otros objetos. Si dos objetos colisionan, ambos deben actualizarse.

Los motores de física modernos (como PhysX, Bullet, Havok) usan sistemas de tareas para distribuir el trabajo de la física entre los núcleos.

La física suele ejecutarse a una tasa fija (por ejemplo, 60 Hz), independientemente del framerate de renderizado. Esto garantiza determinismo: la simulación avanza de forma consistente.

6. Hilos de audio

El hilo de audio mezcla los sonidos del juego y los envía a la tarjeta de sonido. El audio es sensible a la latencia: si hay un retraso de más de 10-20 ms, el usuario lo nota.

El hilo de audio típicamente:

  1. Recibe los sonidos a reproducir: de los sistemas de juego (disparos, pasos, música, etc.).
  2. Mezcla los sonidos: suma las señales de audio de todos los sonidos.
  3. Aplica efectos: reverb, eco, filtro de distancia, etc.
  4. Envía a la tarjeta de sonido: a través de la API de audio (ALSA, WASAPI, CoreAudio).

El hilo de audio se ejecuta con alta prioridad (tiempo real) para garantizar que no se interrumpa. Un retraso en el audio causa "glitches" (clicks, pops) que son muy molestos.

El audio es relativamente fácil de paralelizar: cada sonido se puede mezclar en un hilo separado, y los resultados se suman al final.

7. Hilos de entrada (input)

El hilo de entrada (input thread) lee el estado del teclado, ratón, mando y otros dispositivos de entrada. Es un hilo separado porque la lectura de entrada puede bloquearse (por ejemplo, esperando un evento del mando).

El hilo de entrada:

El buffer de entrada se protege con un mutex o se usa una cola lock-free para evitar bloqueos.

La entrada debe ser responsiva

Si el hilo de entrada se bloquea, el juego no responde a la entrada del usuario. Por eso, el hilo de entrada se ejecuta con alta prioridad y usa operaciones no bloqueantes.

8. Sistemas de tareas (task systems)

Un sistema de tareas (task system) es un mecanismo para distribuir trabajo entre múltiples hilos. En lugar de crear un hilo por tarea (que es costoso), se crea un pool de hilos (workers) y se les asignan tareas (tasks o jobs).

El sistema de tareas funciona así:

  1. El productor crea tareas: el main thread (o un sistema) crea tareas y las pone en una cola.
  2. Los workers toman tareas: los hilos del pool toman tareas de la cola y las ejecutan.
  3. Los workers terminan y toman otra: cuando un worker termina una tarea, toma la siguiente de la cola.
  4. Sincronización: el productor espera a que todas las tareas terminen (con un contador o un barrier).

El sistema de tareas es la base de la paralelización en motores de juego modernos. Permite distribuir el trabajo de renderizado, física, IA, etc. entre todos los núcleos.

Sistema de tareas: un productor crea tareas y las pone en una cola. Workers las toman y ejecutan. Sistema de tareas Productor crea tareas Cola de tareas tarea1, tarea2, ... Worker 1 Worker 2 Worker 3 Worker 4 Los workers toman tareas de la cola y las ejecutan en paralelo
Sistema de tareas: un productor crea tareas, los workers las toman y ejecutan en paralelo.

La cola de tareas debe ser thread-safe: los workers la acceden concurrentemente. Se usa un mutex para proteger la cola, o una cola lock-free.

El productor debe esperar a que todas las tareas terminen antes de continuar. Esto se hace con un contador (decrementado por cada worker al terminar) o con un barrier.

9. Job systems y work stealing

Un job system es un sistema de tareas más sofisticado que permite dependencias entre tareas: una tarea puede depender de otras (no se ejecuta hasta que sus dependencias terminen).

El work stealing es una técnica para balancear la carga entre los workers. Cada worker tiene su propia cola de tareas. Cuando un worker termina sus tareas, "roba" tareas de la cola de otro worker. Esto asegura que todos los workers estén ocupados.

El work stealing es la técnica usada por:

El work stealing es más eficiente que una cola compartida porque reduce la contención: cada worker accede principalmente a su propia cola, y solo roba cuando está vacía.

10. Concurrencia en motores de juego

Los motores de juego modernos usan diferentes estrategias de concurrencia:

MotorEstrategiaCaracterísticas
UnityJob System + Burst CompilerSistema de tareas con work stealing. El Burst Compiler compila código C# a código nativo optimizado.
Unreal EngineTask Graph + async tasksGrafo de tareas con dependencias. Soporta async/await para tareas.
GodotMulti-threading (limitado)Soporte básico de hilos. La mayoría del trabajo se hace en el hilo principal.
Custom enginesJob systems propiosMuchos motores AAA tienen sus propios job systems optimizados.

La tendencia en la industria es hacia los job systems con work stealing, porque ofrecen el mejor balance entre rendimiento y facilidad de uso.

Los datos (posiciones de objetos, etc.) se organizan en arreglos contiguos (data-oriented design) para maximizar el uso de caché y facilitar la paralelización.

11. Un programa que demuestra el game loop

El siguiente programa simula un game loop simple con un sistema de tareas:

game_loop.c

#define _POSIX_C_SOURCE 200809L
#include 
#include 
#include 
#include 
#include 

static double tiempo_ms(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec * 1000.0 + ts.tv_nsec / 1e6;
}

/* Sistema de tareas simple */
static int tareas_pendientes = 0;
static pthread_mutex_t mutex_tareas = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t cond_tareas = PTHREAD_COND_INITIALIZER;

void *worker(void *arg)
{
    int id = (int)(long) arg;
    while (1) {
        pthread_mutex_lock(&mutex_tareas);
        while (tareas_pendientes <= 0)
            pthread_cond_wait(&cond_tareas, &mutex_tareas);
        tareas_pendientes--;
        pthread_mutex_unlock(&mutex_tareas);

        /* Ejecutar tarea */
        printf("  Worker %d: procesando tarea\n", id);
        usleep(10000);  /* simular trabajo */
    }
    return NULL;
}

int main(void)
{
    /* Crear pool de 4 workers */
    pthread_t workers[4];
    for (int i = 0; i < 4; i++)
        pthread_create(&workers[i], NULL, worker, (void *)(long)(i + 1));

    /* Game loop: 5 frames */
    for (int frame = 0; frame < 5; frame++) {
        double t0 = tiempo_ms();

        /* 1. Procesar entrada */
        /* (simulado) */

        /* 2. Crear tareas para este frame */
        int n_tareas = 8;
        pthread_mutex_lock(&mutex_tareas);
        tareas_pendientes = n_tareas;
        pthread_cond_broadcast(&cond_tareas);
        pthread_mutex_unlock(&mutex_tareas);

        /* 3. Esperar a que las tareas terminen */
        while (1) {
            pthread_mutex_lock(&mutex_tareas);
            int restantes = tareas_pendientes;
            pthread_mutex_unlock(&mutex_tareas);
            if (restantes == 0) break;
            usleep(1000);
        }

        /* 4. Renderizar */
        /* (simulado) */

        double t1 = tiempo_ms();
        printf("Frame %d: %.2f ms\n", frame, t1 - t0);
    }

    /* Terminar workers */
    for (int i = 0; i < 4; i++)
        pthread_cancel(workers[i]);
    for (int i = 0; i < 4; i++)
        pthread_join(workers[i], NULL);

    pthread_mutex_destroy(&mutex_tareas);
    pthread_cond_destroy(&cond_tareas);
    printf("Game loop terminado\n");
    return 0;
}

El programa simula un game loop con 5 frames. En cada frame, crea 8 tareas que son procesadas por 4 workers. El main thread espera a que las tareas terminen antes de pasar al siguiente frame.

Este es un ejemplo simplificado. Un motor de juego real usaría un job system con work stealing, dependencias entre tareas, y sincronización más sofisticada.

12. Resumen

13. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. Que es el game loop?

El bucle principal del juego que procesa entrada, actualiza la simulación y renderiza en cada frame.

2. Cuánto tiempo tiene un frame a 60 FPS?

16.67 ms (1000/60).

3. Que es un sistema de tareas?

Un mecanismo que distribuye trabajo entre un pool de workers. Un productor crea tareas, los workers las toman y ejecutan.

4. Que es work stealing?

Una técnica donde un worker que termina sus tareas "roba" tareas de la cola de otro worker. Balancea la carga.

5. Por qué la física se ejecuta a tasa fija?

Para garantizar determinismo: la simulación avanza de forma consistente, independientemente del framerate.

6. Que prioridad tiene el hilo de audio?

Alta (tiempo real) para evitar glitches (clicks, pops) en el audio.

7. Que es un job system?

Un sistema de tareas que soporta dependencias entre tareas: una tarea puede depender de otras.

8. Que motores usan job systems con work stealing?

Unity (Job System), Unreal Engine (Task Graph), y muchos motores AAA.

9. Por qué el render thread es el más importante?

Porque determina el framerate. Si el renderizado tarda más de 16.67 ms, el juego se ve lento.

10. Que es data-oriented design?

Organizar los datos en arreglos contiguos para maximizar el uso de caché y facilitar la paralelización.

Para practicar

  1. Compila y ejecuta game_loop.c. Observa el tiempo de cada frame.
  2. Modifica game_loop.c para usar 8 workers en lugar de 4.
  3. Escribe un programa que implemente work stealing entre workers.
  4. Investiga el Job System de Unity y cómo se usa en la práctica.
  5. Escribe un programa que simule un game loop con física a tasa fija.
  6. Investiga data-oriented design y cómo se aplica en motores de juego.