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:
- Renderizado: dibujar miles de objetos, aplicar shaders, componer la imagen final.
- Física: simular colisiones, gravedad, movimiento de objetos.
- Audio: mezclar sonidos, aplicar efectos, enviar a la tarjeta de sonido.
- IA: decisiones de los enemigos, pathfinding, etc.
- Entrada: leer teclado, ratón, mando.
- Red: enviar y recibir datos en juegos multijugador.
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 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:
- Procesar entrada: leer el teclado, ratón, mando. Actualizar el estado de los botones.
- Actualizar la simulación: física, IA, lógica de juego. Avanzar el tiempo de juego.
- Renderizar: dibujar la escena en la pantalla.
- 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.
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:
- Fixed timestep: la simulación avanza en pasos fijos (por ejemplo, 60 Hz). El renderizado se interpola entre pasos. Garantiza determinismo.
- Variable timestep: la simulación avanza con el tiempo real. Más simple pero puede causar inestabilidad en la física.
3. Los sistemas del juego
Un juego se divide en sistemas, cada uno responsable de una parte de la simulación:
| Sistema | Función | Frecuencia |
|---|---|---|
| Input | Leer teclado, ratón, mando. | Cada frame |
| Physics | Simular movimiento, colisiones, gravedad. | Cada frame (o fixed timestep) |
| AI | Decisiones de enemigos, pathfinding. | Cada N frames |
| Audio | Mezclar sonidos, aplicar efectos. | Cada frame |
| Render | Dibujar la escena en la pantalla. | Cada frame |
| Network | Enviar y recibir datos en multijugador. | Cada frame |
| Game Logic | Ló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:
- Culling: determinar qué objetos son visibles (no están fuera de la cámara).
- Sorting: ordenar los objetos por distancia (para transparencias) y por material (para minimizar cambios de estado).
- Draw calls: enviar comandos a la GPU para dibujar cada objeto.
- Post-processing: aplicar efectos (bloom, HDR, etc.).
- 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 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:
- Por regiones: dividir el mundo en regiones y simular cada región en un hilo.
- Por objetos: distribuir los objetos entre hilos para la detección de colisiones.
- Por sistemas: un hilo para la física de partículas, otro para la física de rigid bodies, etc.
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:
- Recibe los sonidos a reproducir: de los sistemas de juego (disparos, pasos, música, etc.).
- Mezcla los sonidos: suma las señales de audio de todos los sonidos.
- Aplica efectos: reverb, eco, filtro de distancia, etc.
- 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:
- Lee el estado de los dispositivos: teclas presionadas, posición del ratón, botones del mando.
- Actualiza el estado de entrada: un buffer compartido que el main thread lee.
- Envía eventos: notifica al main thread cuando ocurre un evento (tecla presionada, botón clickeado).
El buffer de entrada se protege con un mutex o se usa una cola lock-free para evitar bloqueos.
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í:
- El productor crea tareas: el main thread (o un sistema) crea tareas y las pone en una cola.
- Los workers toman tareas: los hilos del pool toman tareas de la cola y las ejecutan.
- Los workers terminan y toman otra: cuando un worker termina una tarea, toma la siguiente de la cola.
- 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.
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:
- Intel TBB (Threading Building Blocks): una biblioteca de C++ para paralelismo.
- Unreal Engine: usa un job system con work stealing.
- Rust (rayon): una biblioteca de paralelismo basada en work stealing.
- Java (Fork/Join framework): un framework de paralelismo basado en work stealing.
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:
| Motor | Estrategia | Características |
|---|---|---|
| Unity | Job System + Burst Compiler | Sistema de tareas con work stealing. El Burst Compiler compila código C# a código nativo optimizado. |
| Unreal Engine | Task Graph + async tasks | Grafo de tareas con dependencias. Soporta async/await para tareas. |
| Godot | Multi-threading (limitado) | Soporte básico de hilos. La mayoría del trabajo se hace en el hilo principal. |
| Custom engines | Job systems propios | Muchos 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:
#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
- Los videojuegos son aplicaciones de tiempo real con restricciones estrictas de latencia (16.67 ms por frame a 60 FPS).
- El game loop procesa entrada, actualiza la simulación y renderiza en cada frame.
- Los sistemas (física, audio, render, IA) se pueden paralelizar con hilos.
- Los sistemas de tareas distribuyen trabajo entre un pool de workers.
- Work stealing balancea la carga entre workers.
- Los motores modernos (Unity, Unreal) usan job systems con work stealing.
- El main thread prepara las tareas, los workers las ejecutan, y el main espera a que terminen.
- La física suele ejecutarse a tasa fija (60 Hz) para determinismo.
- El audio se ejecuta con alta prioridad (tiempo real) para evitar glitches.
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
- Compila y ejecuta game_loop.c. Observa el tiempo de cada frame.
- Modifica game_loop.c para usar 8 workers en lugar de 4.
- Escribe un programa que implemente work stealing entre workers.
- Investiga el Job System de Unity y cómo se usa en la práctica.
- Escribe un programa que simule un game loop con física a tasa fija.
- Investiga data-oriented design y cómo se aplica en motores de juego.