Volver

Crear un juego de carreras 3D estilo OutRun para el ESP32-S3 sin Z-buffer

En febrero de 2026 me metí en una discusión en Reddit. Alguien afirmaba que los últimos modelos de programación escribían mejor código que cualquier desarrollador humano, y citaba los lanzamientos más recientes de OpenAI y Anthropic como prueba. No estuve de acuerdo. Los modelos son muy buenos con landing pages, backends CRUD y ese tipo de demo de la que hay mil copias en GitHub. Tenía dudas sobre qué pasa cuando la tarea requiere razonamiento espacial, aritmética de coma flotante cuidadosa y una opinión sobre qué se siente bien al jugar.

Así que elegí un proyecto que apenas existe en internet y lo construí yo mismo: un juego de carreras pseudo-3D al estilo de OutRun, corriendo en un ESP32-S3 con una pantalla ILI9341. De paso, les pedí a los modelos que construyeran lo mismo. Este artículo explica cómo funciona el juego, con más profundidad que el artículo más corto que publiqué en Medium, y termina con lo que los modelos hicieron mal. El código fuente completo está en github.com/davidmonterocrespo24/esp32s3-arcade-3d.

El juego corriendo en el ESP32-S3 y una pantalla ILI9341 — la pantalla de inicio con el coche 3D girando, luego la carrera con la carretera, los edificios, el tráfico y el velocímetro

Qué hace el juego

Todo el conjunto son unas 2.500 líneas de C++ en el framework de Arduino, dibujando a través de la librería TFT_eSPI. Incluye:

El hardware es deliberadamente corriente:

ComponenteDetalles
SoCESP32-S3, 240 MHz de doble núcleo, con PSRAM
PantallaTFT ILI9341, 320 por 240 píxeles, RGB565 de 16 bits, por SPI
Pines de pantallaSCK 12, MOSI 11, MISO 13, CS 10, DC 9, RST 8
RetroiluminaciónGPIO 39
BotonesGPIO 17 (izquierda) y GPIO 16 (derecha), con pull-ups internas

El prototipo — un módulo ESP32-S3 sobre una placa perforada, cableado a una placa de pantalla ILI9341 roja, sostenido en una mano

Por qué no hay Z-buffer

La primera respuesta que me dieron tanto ChatGPT como Claude fue que el proyecto no era viable en este hardware, porque un renderizador 3D necesita un búfer de profundidad y el microcontrolador no tiene espacio para uno. Merece la pena hacer las cuentas, porque muestran cuál es la restricción real.

Un fotograma de 320 por 240 en RGB565 son 153.600 bytes. Un búfer de profundidad de 16 bits del mismo tamaño son otros 153.600 bytes. El ESP32-S3 tiene 512 KB de SRAM interna, de la cual una buena parte la ocupan el core de Arduino, el driver de pantalla y el heap, así que dos búferes a pantalla completa no caben cómodamente en memoria interna. Con PSRAM sí caben. El argumento de la memoria es débil.

El argumento más fuerte es el coste por píxel. Un búfer de profundidad implica una lectura, una comparación y una escritura condicional por cada píxel de cada triángulo, en software, en un núcleo que además tiene que rellenar 76.800 píxeles por fotograma y enviarlos por SPI. A 240 MHz ese presupuesto es escaso.

Los juegos de carreras arcade de los años 80 no tenían ni la memoria ni la tasa de relleno, y lo resolvieron con ordenación en lugar de con test de profundidad: dibujar primero lo lejano y dejar que lo cercano lo pinte por encima. Ese es el algoritmo del pintor, y funciona porque una carretera es una escena muy bien comportada. Los segmentos ya están ordenados por distancia, y todo lo demás (árboles, edificios, tráfico) está asociado a un segmento. El único objeto que necesita 3D real es el coche del jugador, y está a una distancia fija delante de la cámara, así que se puede ordenar por su cuenta.

La carretera: segmentos y proyección

La carretera es un array de 200 segmentos, cada uno de 200 unidades de mundo de longitud. Un segmento almacena su curvatura, su elevación, un sprite opcional de cuneta, un flag de túnel y la altura y el color de un edificio a cada lado:

struct Segment {
  float curve;              // Segment curvature
  float y;                  // Height (elevation)
  int8_t spriteType;        // Sprite type (-1 = none)
  float  spriteOffset;      // Lateral sprite offset
  bool   tunnel;            // true = inside tunnel
  int    buildL, buildR;    // Left/Right building height (0 = no building)
  uint16_t colorL, colorR;  // Building facade color
};

La escala del mundo viene del ancho de la carretera: ROAD_W son 2000 unidades para la mitad de la carretera, que tomo como unos 10,5 m, así que una unidad son unos 5,25 mm. Todas las demás constantes de config.h están expresadas en esas unidades.

La técnica central sigue los artículos del racer en JavaScript de Jake Gordon, que son la explicación más clara de carreteras pseudo-3D que conozco. La cámara está CAM_HEIGHT unidades por encima de la carretera. El campo de visión define una profundidad de cámara:

float fovRad = FOV_DEG * PI / 180.0;
cameraDepth  = 1.0 / tanf(fovRad / 2.0);
playerZdist  = CAM_HEIGHT * cameraDepth;

Proyectar el borde de un segmento es entonces una división y dos multiplicaciones. camZ1 es la distancia desde la cámara al borde cercano del segmento n, y sc1 es la escala a esa distancia:

float sc1   = cameraDepth / camZ1;
float cyp1  = p1Y - camY;                       // height relative to the camera
float cxp1  = playerX * ROAD_W - curveX;        // lateral offset incl. the curve
int16_t sy1 = SCR_CY - (int)(sc1 * cyp1 * SCR_CY);
int16_t sx1 = SCR_CX + (int)(sc1 * (-cxp1) * SCR_CX);
int16_t sw1 = (int)(sc1 * ROAD_W * SCR_CX);

Las curvas son el truco clásico: la curve de un segmento no es un ángulo, es un cambio por segmento en el desplazamiento horizontal. A medida que el bucle se aleja de la cámara acumula curveDX += seg.curve y curveX += curveDX, así que el desplazamiento crece cuadráticamente con la distancia y la carretera se curva suavemente. Las colinas son aún más simples: cada segmento tiene su propia y, y la proyección usa la diferencia de altura con la cámara, así que una carretera que sube trepa por la pantalla y una bajada desaparece por debajo del horizonte.

El detalle interesante es que drawRoad() hace dos pasadas sobre los segmentos, en direcciones opuestas.

  1. De delante hacia atrás, proyectando. El primer bucle va desde el segmento más cercano hacia fuera, proyecta cada borde en rCache[n] y registra en rClip[n] la fila de pantalla más baja que sigue siendo visible a esa distancia. A medida que avanza el bucle, maxy solo se mueve hacia arriba: un segmento detrás de la cresta de una colina se proyecta por debajo de la cresta y queda recortado. Así es como las colinas ocluyen lo que hay detrás sin ninguna comparación de profundidad.
  2. De atrás hacia delante, dibujando. El segundo bucle va desde el extremo lejano hacia la cámara y dibuja la hierba, las bandas rugosas, la carretera y las líneas de carril de cada segmento como bandas horizontales entre rCache[n] y rCache[n-1], recortadas por rClip. Cerca de la cámara un segmento puede tener muchas filas de alto, así que se subdivide en hasta tres bandas con el ancho interpolado entre los dos bordes, lo que evita que las curvas parezcan trapecios apilados.

Los edificios y el túnel comparten el bucle

Una versión temprana dibujaba la carretera en un bucle y los edificios en otro. Se veía bien en terreno llano y se desmoronaba en las colinas: un edificio que debería estar oculto tras una cresta se pintaba por encima de ella, porque el segundo bucle no tenía ni idea de lo que ya había dibujado el primero.

La solución fue dibujar todo lo que pertenece a un segmento dentro del mismo bucle de atrás hacia delante. Para cada segmento, en orden: las paredes y el techo del túnel si el segmento está dentro del túnel, los edificios si está fuera, y luego la superficie de la carretera. Un edificio es un par de quads (cara frontal y cara lateral) construidos a partir de los bordes proyectados de la carretera del segmento y del anterior:

void drawQuad(int x1, int y1, int x2, int y2, int x3, int y3, int x4, int y4, uint16_t c) {
  spr.fillTriangle(x1, y1, x2, y2, x3, y3, c);
  spr.fillTriangle(x1, y1, x3, y3, x4, y4, c);
}

El túnel es la misma idea con los quads girados hacia dentro: un quad de techo a una altura fija sobre la carretera, dos quads de pared, una luz amarilla cada cuatro segmentos y un portal gris dibujado en el primer segmento. Como todo se emite en orden de pintor, la entrada de un túnel a mitad de una colina se ve correcta sin ningún caso especial.

Los sprites de cuneta y los coches de tráfico van en un tercer bucle corto, después de que la carretera esté completa. Se dibujan como formas planas escaladas por la escala de proyección del segmento y recortadas contra rClip, así que un árbol detrás de una colina queda cortado en la cresta exactamente igual que la carretera.

Dentro del túnel de noche — luces en el techo, paredes azules, el coche del jugador y el velocímetro

El coche del jugador es 3D de verdad

No quería un sprite para el jugador. El coche es una malla OBJ convertida fuera de línea en una cabecera C por un pequeño script de Python, así que no se parsea nada en tiempo de ejecución:

python assets/obj_to_header.py assets/Car2.obj > car2_mesh.h
python assets/png_to_rgb565.py assets/car2.png > car2_texture.h

La cabecera contiene 428 vértices con posición y coordenadas de textura, y 312 triángulos como lista de índices. La textura de 128 por 128 son 32 KB de RGB565 en flash, leídos con pgm_read_word() para que nunca toquen la RAM.

Cada fotograma la malla pasa por un pequeño pipeline fijo en render_player.cpp:

  1. Transformar. Cada vértice se rota alrededor del eje vertical por un ángulo derivado de la posición lateral del coche (el coche gira visiblemente hacia la curva), y luego se inclina por la pendiente de la carretera, promediada sobre los siguientes seis segmentos y suavizada en el tiempo para que el coche se incline en las colinas sin temblores.
  2. Proyectar. x = centerX + rx * fov / z, con una distancia de cámara fija y una distancia focal de 130 píxeles. Los vértices detrás de la cámara se marcan y sus triángulos se omiten.
  3. Ordenar. Se calcula la profundidad media de cada triángulo y se ordenan los 312 triángulos de lejos a cerca. Un ordenamiento por inserción basta a este tamaño y es casi gratis cuando el orden apenas cambia entre fotogramas.
  4. Descartar e iluminar. Los triángulos que miran hacia otro lado se descartan usando el signo del producto vectorial 2D. La normal de la cara se calcula en espacio de objeto y se multiplica escalarmente por una luz fija desde arriba y ligeramente al frente, dando un brillo entre 0,35 y 1,0.
  5. Rasterizar. Cada triángulo se dibuja con un rasterizador por scanlines con mapeado de textura afín.

El rasterizador es la parte en la que pasé más tiempo. Ordena los tres vértices por Y de pantalla, recorre las filas entre la superior y la inferior, y para cada fila interpola la X izquierda y derecha y las coordenadas de textura a lo largo de los dos bordes activos. Luego avanza a lo largo de la fila, muestrea la textura, aplica la iluminación y escribe un píxel:

for (int x = x0; x <= x1; x++) {
  float t = (x - xL) * dx;
  float u = uL + t * (uR - uL);
  float v = vL + t * (vR - vL);

  int tx = (int)(u * (CAR2_TEX_W - 1));
  int ty = (int)((1.0f - v) * (CAR2_TEX_H - 1));  // OBJ V runs bottom-up
  tx = max(0, min(tx, CAR2_TEX_W - 1));
  ty = max(0, min(ty, CAR2_TEX_H - 1));

  uint16_t texel = pgm_read_word(&car2_texture[ty * CAR2_TEX_W + tx]);
  if (light < 0.99f) {
    uint8_t r = ((texel >> 11) & 0x1F) * light;
    uint8_t g = ((texel >>  5) & 0x3F) * light;
    uint8_t b = ( texel        & 0x1F) * light;
    texel = (r << 11) | (g << 5) | b;
  }
  spr.drawPixel(x, y, texel);
}

El mapeado afín interpola u y v linealmente en espacio de pantalla, lo que no es correcto en perspectiva. En una pared grande produce el bamboleo por el que se hicieron famosos los juegos de PlayStation. En un coche que ocupa alrededor de un quinto de la pantalla, siempre a la misma distancia, el error está por debajo de un píxel y la división por píxel que necesitaría un mapeado correcto no merece la pena. El renderizador también rechaza cualquier scanline de más de 160 píxeles de ancho y cualquier triángulo con un vértice a más de 20 píxeles fuera de pantalla. Ambas protecciones existen por bugs reales: un único vértice mal ordenado o recortado se convierte en una raya que cruza todo el fotograma, y es mucho más barato descartar el triángulo que recortarlo bien.

Bajo el coche hay una elipse oscura dibujada como una pila de líneas horizontales. Se desplaza con la posición lateral del coche y es lo más barato del proyecto con el mayor efecto sobre lo asentado que parece el coche.

El coche del jugador derrapando en una curva al atardecer junto a edificios, con el velocímetro marcando 262 km/h

Cielo, parallax y hora del día

El cielo no se dibuja cada fotograma. En el arranque, initBackground() construye un sprite de 640 por 120 en PSRAM (el doble del ancho de la pantalla, la mitad superior de la pantalla de alto) con un degradado vertical, un sol y dos capas de un skyline procedural con ventanas iluminadas. Cada fotograma el bucle principal lo desplaza una cantidad proporcional a la curva y la velocidad actuales, y lo blitea dos veces para que el wrap-around sea continuo:

int bgX = (int)skyOffset % (SCR_W * 2);
bgSpr.pushToSprite(&spr, -bgX, 0);
bgSpr.pushToSprite(&spr, (SCR_W * 2) - bgX, 0);

Este es el efecto que usa Horizon Chase: el horizonte se desliza lateralmente al girar, lo que vende la curva mucho más que la geometría de la carretera por sí sola.

La paleta vive en colors.cpp como tres conjuntos de colores RGB565 para día, atardecer y noche: cielo, hierba clara y oscura, carretera clara y oscura, bandas rugosas, marcas de carril y niebla. El juego cambia al siguiente conjunto cada 180.000 unidades de mundo de recorrido, que a velocidad máxima son unos catorce segundos. La niebla es exponencial en distancia y se mezcla por segmento:

float expFog(float d, float density) {
  return 1.0f - clampF(1.0f / expf(d * d * density), 0, 1);
}

lerpCol() mezcla dos valores RGB565 canal a canal, lo que basta para fundir la carretera y la hierba con el color de la niebla sin convertir nunca a 24 bits.

Física: lo que hace que se sienta como un coche

Los gráficos hacen que un juego de carreras llame la atención. La física decide si alguien juega más de un minuto. Todo el ajuste vive en config.h:

#define SPEED_MULTIPLIER   65.0f   // maxSpeed = SEG_LEN * SPEED_MULTIPLIER (~246 km/h)
#define ACCEL_TARGET       0.9f    // targetAccel = maxSpeed * ACCEL_TARGET
#define ACCEL_RAMP         180.0f  // How fast acceleration ramps up (u/s^2)
#define FRICTION           0.996f  // Friction per frame (braking ~3.3s from max)
#define GRAVITY_FACTOR     1600.0f // Effect of slopes on acceleration
#define CENTRIFUGAL        0.18f   // Centrifugal force in curves
#define CURVE_FORCE        3.0f    // Lateral force multiplier in curves
#define LATERAL_FRICTION   0.90f   // Lateral velocity damping per frame

La actualización en physics.cpp hace cuatro cosas en orden. La pendiente se lee de la diferencia de altura entre el segmento actual y el anterior y se convierte en una aceleración, así que las subidas pierden velocidad y las bajadas la ganan. La fricción se aplica como un decaimiento multiplicativo. La aceleración, que sube con el tiempo en lugar de saltar a su objetivo, se integra en la velocidad. Luego la curva empuja el coche lateralmente:

float curveForce = segments[pSeg].curve * centrifugal * spPct;
velocityX += curveForce * dt * CURVE_FORCE;   // lateral velocity builds up
velocityX *= LATERAL_FRICTION;                // and decays every frame
playerX   -= velocityX * dt;

La velocidad lateral es lo que da la deriva. Un coche que entra en una curva no se desliza inmediatamente; la fuerza se acumula, el deslizamiento crece, y cuando la curva termina el amortiguamiento devuelve el coche. El ángulo de la deriva se calcula entonces a partir de la relación entre la velocidad lateral y la longitudinal y se usa solo para mostrarlo.

Las colisiones se gestionan por distancia a lo largo de la pista más el solapamiento en el eje lateral. Golpear por detrás a un coche de tráfico más lento te baja al setenta por ciento de su velocidad; tocar una pared del túnel, un árbol o salirse por completo de la carretera resta más velocidad, y cualquiera de ellos por encima de un umbral dispara el estado de choque de dos segundos.

La compilación del repositorio funciona como demo: un piloto automático lee la curva que viene y contravolantea hacia ella, y el acelerador está siempre a fondo. Los dos botones se declaran y se configuran con pull-up en setup(), y el emulador de PC mapea las teclas de flecha a ellos, así que la dirección manual son unas pocas líneas en handleInput().

Ninguna de estas constantes salió de una fórmula. Esbocé la perspectiva en papel, construí las curvas de velocidad en una hoja de cálculo, y luego cambié un número cada vez hasta que el coche se sintió bien. Ese bucle solo funcionó porque cada iteración tardaba segundos, lo que me lleva al emulador.

Desarrollar en un PC con Raylib

Flashear un ESP32-S3 tarda lo suficiente como para que ajustar una constante a ojo sea doloroso. Así que el mismo código fuente compila en Windows contra Raylib, con las llamadas de Arduino y TFT_eSPI reemplazadas por una fina capa de compatibilidad en la carpeta emulator/:

Un ejemplo del tipo de cosa que la capa de compatibilidad tiene que hacer bien:

void TFT_eSprite::fillTriangle(int32_t x0, int32_t y0, int32_t x1, int32_t y1,
                               int32_t x2, int32_t y2, uint16_t color) {
  BeginTextureMode(sd->texture);
  // Draw twice so both winding orders show; Raylib culls one of them
  DrawTriangle({x0, y0}, {x1, y1}, {x2, y2}, r565(color));
  DrawTriangle({x0, y0}, {x2, y2}, {x1, y1}, r565(color));
  EndTextureMode();
}

A TFT_eSPI no le importa el orden de los vértices, a Raylib sí, y el código de la carretera nunca promete uno. Dibujar cada uno dos veces no cuesta nada en una GPU y hizo que el emulador fuera fiel píxel a píxel a la placa.

Con esto en su sitio, el bucle era editar, make, ejecutar, en unos segundos. Las capturas de este artículo son del emulador; la animación de arriba es la placa.

Memoria y presupuesto de fotograma en la placa

A dónde van los bytes:

BúferTamañoDónde
Sprite de fotograma, 320 por 240 por 16 bits153.600 BPSRAM
Sprite de cielo y skyline, 640 por 120 por 16 bits153.600 BPSRAM
Textura del coche, 128 por 128 por 16 bits32.768 BFlash
Malla del coche, 428 vértices y 312 triángulosunos 10 KBFlash
Pista, 200 segmentosunos 6 KBSRAM interna
Cachés de proyección y arrays por fotogramaunos pocos KBSRAM interna

Los dos sprites se crean con spr.setAttribute(PSRAM_ENABLE, true) antes de createSprite(). Esa única línea es la diferencia entre un juego que funciona y uno que falla al reservar memoria. Los 8 MB de PSRAM del módulo están casi sin usar; lo que importa es que los dos búferes a ancho completo no compitan con el core de Arduino por la memoria interna.

El fotograma se compone enteramente en el sprite y se envía al panel una vez por bucle con spr.pushSprite(0, 0). Eso es lo que elimina el tearing, y también es el techo de la tasa de fotogramas: 153.600 bytes por SPI a 40 MHz tardan unos 31 ms por sí solos, antes de dibujar nada. En la placa el juego se asienta en torno a 30 fotogramas por segundo, que es exactamente lo que predice esa aritmética. Subir el reloj de SPI en el User_Setup.h de TFT_eSPI es el primer sitio donde mirar si quieres más.

De día en una recta con edificios a ambos lados y un coche de tráfico amarillo delante

En qué fallaron los modelos de IA

En paralelo a la versión escrita a mano, les pedí a los modelos de programación disponibles en febrero de 2026 que construyeran el mismo juego a partir de una descripción. Esto es lo que pasó, lo más justo que puedo contarlo:

No creo que nada de esto sea sorprendente. Las tres cosas que el proyecto necesitaba más son las tres cosas de las que un modelo de lenguaje tiene menos:

Sí usé IA en este proyecto, y fue útil exactamente para las cosas en las que es buena: montar la estructura de módulos, escribir los conversores de OBJ y PNG, y refactorizar código de dibujo repetitivo una vez que el diseño estaba asentado. Cada constante, cada proyección y cada decisión de ordenación se tomaron a mano. El modelo fue una herramienta, no el arquitecto. Eso era verdad en febrero de 2026 y puede que no lo sea para siempre; si consigues que un modelo produzca una versión jugable de esto solo a partir de la descripción, de verdad que me gustaría verlo.

Constrúyelo tú mismo

El repositorio es github.com/davidmonterocrespo24/esp32s3-arcade-3d.

En la placa:

  1. Cablea un ILI9341 al ESP32-S3 en los pines de la tabla de hardware de arriba, y los dos pulsadores entre GPIO 17, GPIO 16 y tierra.
  2. Instala la librería TFT_eSPI y configura los mismos pines y el reloj de SPI en su User_Setup.h.
  3. Abre car_game.ino en el IDE de Arduino, selecciona ESP32S3 Dev Module, activa la PSRAM en las opciones de la placa, y sube el código.

En un PC (Windows, con MinGW y Raylib instalados):

cd emulator/
make
./car_game_emu.exe

El mismo cableado está descrito en el diagram.json del repositorio, y tanto el ESP32-S3 DevKit como el ILI9341 existen en el catálogo de componentes de Velxio si quieres montar el circuito sin soldador. Todavía no he probado a ejecutar el juego completo dentro de la placa emulada; la configuración de pines de TFT_eSPI y el sprite en PSRAM lo convierten en una prueba más interesante que un blink sketch, y lo tengo en mi lista.

Si lo construyes, cambias el generador de pistas, o consigues que un modelo lo escriba, abre un issue en el repositorio. Los leo todos.


Compartir en:
David Montero

Escrito por

David Montero

Creator of Velxio, the open-source circuit and Arduino simulator.

GitHub velxio.dev

Artículos relacionados


Artículo anterior
Las nuevas placas XIAO IPS Display de Seeed, funcionando en tu navegador días después del lanzamiento
Artículo siguiente
Los chips personalizados crecen — un solo editor, controles deslizantes en vivo y una biblioteca personal de chips