Indietro

Creare un gioco di corse 3D in stile OutRun per l'ESP32-S3 senza Z-buffer

A febbraio 2026 sono finito in una discussione su Reddit. Qualcuno sosteneva che i modelli di coding più recenti scrivessero codice migliore di qualsiasi sviluppatore umano, e citava le ultime release di OpenAI e Anthropic come prova. Non ero d’accordo. I modelli sono molto bravi con landing page, back end CRUD e il tipo di demo che ha mille copie su GitHub. Avevo dubbi su cosa succede quando il compito richiede ragionamento spaziale, aritmetica in virgola mobile accurata e un’opinione su cosa sia piacevole da giocare.

Così ho scelto un progetto che a malapena esiste su internet e l’ho costruito da solo: un gioco di corse pseudo-3D nello stile di OutRun, in esecuzione su un ESP32-S3 con un display ILI9341. Lungo il percorso ho chiesto ai modelli di costruire la stessa cosa. Questo articolo spiega come funziona il gioco, in modo più approfondito rispetto al breve resoconto che ho pubblicato su Medium, e si conclude con ciò che i modelli hanno sbagliato. Il codice sorgente completo è su github.com/davidmonterocrespo24/esp32s3-arcade-3d.

Il gioco in esecuzione sull'ESP32-S3 e un display ILI9341: la schermata iniziale con l'auto 3D che ruota, poi la gara con la strada, gli edifici, il traffico e il tachimetro

Cosa fa il gioco

Il tutto è circa 2.500 righe di C++ nel framework Arduino, disegnate tramite la libreria TFT_eSPI. Include:

L’hardware è deliberatamente ordinario:

ComponenteDettagli
SoCESP32-S3, 240 MHz dual-core, con PSRAM
DisplayILI9341 TFT, 320 per 240 pixel, RGB565 a 16 bit, su SPI
Pin del displaySCK 12, MOSI 11, MISO 13, CS 10, DC 9, RST 8
RetroilluminazioneGPIO 39
PulsantiGPIO 17 (sinistra) e GPIO 16 (destra), con pull-up interni

Il prototipo: un modulo ESP32-S3 su una basetta millefori, collegato a una scheda display ILI9341 rossa, tenuto in mano

Perché non c’è uno Z-buffer

La prima risposta che sia ChatGPT sia Claude mi hanno dato è stata che il progetto non era fattibile su questo hardware, perché un renderer 3D ha bisogno di un depth buffer e il microcontrollore non ha spazio per uno. Vale la pena fare i calcoli, perché mostrano qual è il vero vincolo.

Un frame di 320 per 240 in RGB565 è 153.600 byte. Un depth buffer a 16 bit della stessa dimensione è altri 153.600 byte. L’ESP32-S3 ha 512 KB di SRAM interna, di cui buona parte è occupata dal core Arduino, dal driver del display e dall’heap, quindi due buffer a schermo intero non entrano comodamente nella memoria interna. Con la PSRAM entrano. L’argomento della memoria è debole.

L’argomento più forte è il costo per pixel. Un depth buffer significa una lettura, un confronto e una scrittura condizionale per ogni pixel di ogni triangolo, in software, su un core che deve anche riempire 76.800 pixel per frame e spingerli fuori via SPI. A 240 MHz quel budget è risicato.

I giochi di corse arcade degli anni ‘80 non avevano né la memoria né la fill rate, e li hanno risolti con l’ordinamento invece del depth testing: disegna prima le cose lontane e lascia che quelle vicine le coprano. Questo è l’algoritmo del pittore, e funziona perché una strada è una scena molto ben comportata. I segmenti sono già ordinati per distanza, e tutto il resto (alberi, edifici, traffico) è agganciato a un segmento. L’unico oggetto che necessita di vero 3D è l’auto del giocatore, e si trova a una distanza fissa davanti alla telecamera, quindi può essere ordinata da sola.

La strada: segmenti e proiezione

La strada è un array di 200 segmenti, ciascuno lungo 200 unità mondo. Un segmento memorizza la sua curvatura, la sua elevazione, uno sprite opzionale a bordo strada, un flag tunnel e l’altezza e il colore di un edificio su ciascun lato:

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 scala del mondo deriva dalla larghezza della strada: ROAD_W è 2000 unità per metà della strada, che considero essere circa 10,5 m, quindi un’unità è circa 5,25 mm. Ogni altra costante in config.h è espressa in quelle unità.

La tecnica principale segue gli articoli sul racer in JavaScript di Jake Gordon, che sono la spiegazione più chiara delle strade pseudo-3D che conosca. La telecamera si trova CAM_HEIGHT unità sopra la strada. Il campo visivo definisce una profondità della telecamera:

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

Proiettare il bordo di un segmento è quindi una divisione e due moltiplicazioni. camZ1 è la distanza dalla telecamera al bordo vicino del segmento n, e sc1 è la scala a quella distanza:

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);

Le curve sono il trucco classico: la curve di un segmento non è un angolo, è un cambiamento per segmento dell’offset orizzontale. Mentre il loop si allontana dalla telecamera accumula curveDX += seg.curve e curveX += curveDX, così l’offset cresce quadraticamente con la distanza e la strada si piega dolcemente. Le salite sono ancora più semplici: ogni segmento ha il proprio y, e la proiezione usa la differenza di altezza rispetto alla telecamera, così una strada in salita sale sullo schermo e un avvallamento scompare sotto l’orizzonte.

Il dettaglio interessante è che drawRoad() fa due passaggi sui segmenti, in direzioni opposte.

  1. Davanti verso dietro, proiettando. Il primo loop va dal segmento più vicino verso l’esterno, proietta ogni bordo in rCache[n], e registra in rClip[n] la riga dello schermo più bassa ancora visibile a quella distanza. Mentre il loop avanza, maxy si muove solo verso l’alto: un segmento dietro la cresta di una collina si proietta sotto la cresta e viene ritagliato. Questo è come le colline occludono ciò che sta dietro di loro senza alcun confronto di profondità.
  2. Dietro verso davanti, disegnando. Il secondo loop va dall’estremità più lontana verso la telecamera e disegna l’erba, le strisce sonore, la strada e le linee di corsia di ogni segmento come bande orizzontali tra rCache[n] e rCache[n-1], ritagliate da rClip. Vicino alla telecamera un segmento può essere alto molte righe, quindi viene suddiviso in fino a tre bande con la larghezza interpolata tra i due bordi, il che evita che le curve sembrino trapezi impilati.

Edifici e tunnel condividono il loop

Una versione iniziale disegnava la strada in un loop e gli edifici in un altro. Sembrava giusto su terreno pianeggiante e crollava sulle colline: un edificio che doveva essere nascosto dietro una cresta veniva dipinto sopra di essa, perché il secondo loop non aveva idea di cosa avesse già disegnato il primo.

La soluzione è stata disegnare tutto ciò che appartiene a un segmento all’interno dello stesso loop dietro-verso-davanti. Per ogni segmento, in ordine: i muri e il soffitto del tunnel se il segmento è dentro il tunnel, gli edifici se è fuori, poi la superficie stradale. Un edificio è una coppia di quad (faccia frontale e faccia laterale) costruiti dai bordi proiettati della strada del segmento e di quello precedente:

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);
}

Il tunnel è la stessa idea con i quad girati verso l’interno: un quad del soffitto a un’altezza fissa sopra la strada, due quad dei muri, una luce gialla ogni quattro segmenti e un portale grigio disegnato sul primo segmento. Poiché tutto è emesso in ordine del pittore, un ingresso del tunnel a metà di una collina appare corretto senza casi speciali.

Gli sprite a bordo strada e le auto del traffico vanno in un terzo, breve loop dopo che la strada è completa. Sono disegnati come forme piatte scalate dalla scala di proiezione del segmento e ritagliati su rClip, così un albero dietro una collina viene tagliato alla cresta esattamente come lo è la strada.

Dentro il tunnel di notte: luci a soffitto, muri blu, l'auto del giocatore e il tachimetro

L’auto del giocatore è vero 3D

Non volevo uno sprite per il giocatore. L’auto è una mesh OBJ convertita offline in un header C da un piccolo script Python, così nulla viene analizzato a runtime:

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

L’header contiene 428 vertici con posizione e coordinate di texture, e 312 triangoli come lista di indici. La texture 128 per 128 è 32 KB di RGB565 in flash, letta con pgm_read_word() così non tocca mai la RAM.

Ogni frame la mesh passa attraverso una piccola pipeline fissa in render_player.cpp:

  1. Trasformazione. Ogni vertice è ruotato attorno all’asse verticale di un angolo derivato dalla posizione laterale dell’auto (l’auto visibilmente si inclina nella curva), poi inclinato dalla pendenza della strada, mediata sui successivi sei segmenti e smussata nel tempo così l’auto si inclina sulle colline senza tremolii.
  2. Proiezione. x = centerX + rx * fov / z, con una distanza fissa della telecamera e una lunghezza focale di 130 pixel. I vertici dietro la telecamera sono segnalati e i loro triangoli saltati.
  3. Ordinamento. La profondità media di ogni triangolo è calcolata e i 312 triangoli sono ordinati dal più lontano al più vicino. Un insertion sort è sufficiente a questa dimensione ed è quasi gratuito quando l’ordine cambia appena tra i frame.
  4. Culling e illuminazione. I triangoli rivolti altrove sono scartati usando il segno del prodotto vettoriale 2D. La normale della faccia è calcolata nello spazio oggetto e moltiplicata scalarmente con una luce fissa dall’alto e leggermente davanti, dando una luminosità tra 0,35 e 1,0.
  5. Rasterizzazione. Ogni triangolo è disegnato da un rasterizzatore a scanline con mappatura affine delle texture.

Il rasterizzatore è la parte su cui ho speso più tempo. Ordina i tre vertici per Y sullo schermo, percorre le righe tra quella superiore e quella inferiore, e per ogni riga interpola la X sinistra e destra e le coordinate di texture lungo i due bordi attivi. Poi avanza lungo la riga, campiona la texture, applica l’illuminazione e scrive un pixel:

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);
}

La mappatura affine interpola u e v linearmente nello spazio dello schermo, il che non è prospetticamente corretto. Su un muro grande produce l’oscillazione per cui i giochi PlayStation erano famosi. Su un’auto che occupa circa un quinto dello schermo, sempre alla stessa distanza, l’errore è sotto il pixel e la divisione per pixel che una mappatura corretta richiederebbe non vale la pena pagarla. Il renderer rifiuta anche qualsiasi scanline più larga di 160 pixel e qualsiasi triangolo con un vertice più di 20 pixel fuori schermo. Entrambe le protezioni esistono a causa di bug reali: un singolo vertice mal ordinato o ritagliato si trasforma in una striscia attraverso l’intero frame, ed è molto più economico scartare il triangolo che ritagliarlo correttamente.

Sotto l’auto c’è un’ellisse scura disegnata come una pila di linee orizzontali. Si sposta con la posizione laterale dell’auto ed è la cosa più economica del progetto con il maggiore effetto su quanto l’auto sembri ancorata al suolo.

L'auto del giocatore che derapa in una curva al tramonto oltre gli edifici, con il tachimetro che segna 262 km/h

Cielo, parallasse e ora del giorno

Il cielo non è disegnato a ogni frame. All’avvio, initBackground() costruisce uno sprite 640 per 120 in PSRAM (il doppio della larghezza dello schermo, alto la metà superiore dello schermo) con un gradiente verticale, un sole, e due livelli di uno skyline procedurale con finestre illuminate. Ogni frame il loop principale lo scorre di una quantità proporzionale alla curva e alla velocità attuali, e lo blitta due volte così il wrap-around è senza cuciture:

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

Questo è l’effetto che usa Horizon Chase: l’orizzonte scorre lateralmente mentre si sterza, il che vende la curva molto più della sola geometria della strada.

La palette vive in colors.cpp come tre set di colori RGB565 per giorno, tramonto e notte: cielo, erba chiara e scura, strada chiara e scura, strisce sonore, segnaletica di corsia e nebbia. Il gioco passa al set successivo ogni 180.000 unità mondo di viaggio, che alla velocità massima sono circa quattordici secondi. La nebbia è esponenziale in distanza e miscelata per segmento:

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

lerpCol() miscela due valori RGB565 canale per canale, il che è sufficiente per far svanire la strada e l’erba nel colore della nebbia senza mai convertire a 24 bit.

Fisica: cosa la fa sembrare un’auto

La grafica fa notare un gioco di corse. La fisica decide se qualcuno ci gioca per più di un minuto. Tutta la messa a punto vive in 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

L’aggiornamento in physics.cpp fa quattro cose in ordine. La pendenza è letta dalla differenza di altezza tra il segmento corrente e quello precedente e trasformata in un’accelerazione, così le salite sottraggono velocità e le discese la aggiungono. L’attrito è applicato come decadimento moltiplicativo. L’accelerazione, che sale nel tempo invece di saltare al suo target, è integrata nella velocità. Poi la curva spinge l’auto 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 velocità laterale è ciò che dà la deriva. Un’auto che entra in curva non scivola immediatamente; la forza si accumula, lo scivolamento cresce, e quando la curva finisce lo smorzamento riporta l’auto indietro. L’angolo della deriva è poi calcolato dal rapporto tra velocità laterale e in avanti e usato solo per la visualizzazione.

Le collisioni sono gestite tramite la distanza lungo il tracciato più la sovrapposizione sull’asse laterale. Colpire da dietro un’auto del traffico più lenta ti fa scendere al settanta percento della sua velocità; toccare un muro del tunnel, un albero o uscire completamente dalla strada sottrae più velocità, e uno qualsiasi di questi sopra una soglia attiva lo stato di incidente di due secondi.

La build nel repository funziona come demo: un autopilota legge la curva imminente e controsterza dentro di essa, e l’acceleratore è sempre premuto. I due pulsanti sono dichiarati e messi in pull-up in setup(), e l’emulatore PC mappa i tasti freccia su di essi, quindi lo sterzo manuale è qualche riga in handleInput().

Nessuna di queste costanti è venuta da una formula. Ho abbozzato la prospettiva su carta, costruito le curve di velocità in un foglio di calcolo, e poi cambiato un numero alla volta finché l’auto non è sembrata giusta. Quel ciclo ha funzionato solo perché ogni iterazione richiedeva secondi, il che mi porta all’emulatore.

Sviluppare su PC con Raylib

Flasciare un ESP32-S3 richiede abbastanza tempo che mettere a punto una costante a sensazione è doloroso. Così lo stesso sorgente compila su Windows contro Raylib, con le chiamate Arduino e TFT_eSPI sostituite da un sottile shim nella cartella emulator/:

Un esempio del tipo di cosa che lo shim deve gestire correttamente:

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 non importa l’ordine di avvolgimento, a Raylib sì, e il codice della strada non ne promette mai uno. Disegnare ciascuno due volte non costa nulla su una GPU e ha reso l’emulatore fedele al pixel rispetto alla scheda.

Con questo in atto il ciclo era modifica, make, esegui, in pochi secondi. Gli screenshot in questo articolo provengono dall’emulatore; l’animazione in alto è la scheda.

Memoria e budget dei frame sulla scheda

Dove vanno i byte:

BufferDimensioneDove
Sprite del frame, 320 per 240 per 16 bit153.600 BPSRAM
Sprite di cielo e skyline, 640 per 120 per 16 bit153.600 BPSRAM
Texture dell’auto, 128 per 128 per 16 bit32.768 BFlash
Mesh dell’auto, 428 vertici e 312 triangolicirca 10 KBFlash
Tracciato, 200 segmenticirca 6 KBSRAM interna
Cache di proiezione e array per framequalche KBSRAM interna

I due sprite sono creati con spr.setAttribute(PSRAM_ENABLE, true) prima di createSprite(). Quella singola riga è la differenza tra un gioco che funziona e uno che non riesce ad allocare. Gli 8 MB di PSRAM sul modulo sono per lo più inutilizzati; ciò che conta è che i due buffer a larghezza intera non competano con il core Arduino per la memoria interna.

Il frame è composto interamente nello sprite e spinto al pannello una volta per loop con spr.pushSprite(0, 0). Questo è ciò che elimina il tearing, ed è anche il tetto del frame rate: 153.600 byte su SPI a 40 MHz richiedono circa 31 ms da soli, prima di qualsiasi disegno. Sulla scheda il gioco si assesta intorno ai 30 frame al secondo, che è esattamente ciò che quell’aritmetica prevede. Alzare il clock SPI nel User_Setup.h di TFT_eSPI è il primo posto da guardare se vuoi di più.

Giorno su un rettilineo con edifici su entrambi i lati e un'auto del traffico gialla davanti

Dove i modelli AI sono risultati carenti

In parallelo alla versione scritta a mano, ho chiesto ai modelli di coding disponibili a febbraio 2026 di costruire lo stesso gioco da una descrizione. Questo è quello che è successo, nel modo più equo in cui riesco a metterlo:

Non penso che nulla di tutto ciò sia sorprendente. Le tre cose di cui il progetto aveva più bisogno sono le tre cose di cui un modello linguistico ha meno:

Ho usato l’AI su questo progetto, ed è stata utile esattamente per le cose in cui è brava: impostare la struttura dei moduli, scrivere i convertitori OBJ e PNG, e rifattorizzare il codice di disegno ripetitivo una volta che il design era consolidato. Ogni costante, ogni proiezione e ogni decisione di ordinamento è stata presa a mano. Il modello è stato uno strumento, non l’architetto. Questo era vero a febbraio 2026 e potrebbe non esserlo per sempre; se riesci a far produrre a un modello una versione giocabile di questo dalla sola descrizione, mi piacerebbe davvero vederla.

Costruiscilo tu stesso

Il repository è github.com/davidmonterocrespo24/esp32s3-arcade-3d.

Sulla scheda:

  1. Collega un ILI9341 all’ESP32-S3 sui pin nella tabella hardware sopra, e i due pulsanti tra GPIO 17, GPIO 16 e massa.
  2. Installa la libreria TFT_eSPI e imposta gli stessi pin e il clock SPI nel suo User_Setup.h.
  3. Apri car_game.ino nell’Arduino IDE, seleziona ESP32S3 Dev Module, abilita la PSRAM nelle opzioni della scheda, e carica.

Su un PC (Windows, MinGW e Raylib installati):

cd emulator/
make
./car_game_emu.exe

Lo stesso cablaggio è descritto nel diagram.json del repository, e sia l’ESP32-S3 DevKit sia l’ILI9341 esistono nel catalogo componenti di Velxio se vuoi disporre il circuito senza un saldatore. Non ho ancora provato a eseguire il gioco completo dentro la scheda emulata; il setup dei pin di TFT_eSPI e lo sprite in PSRAM lo rendono un test più interessante di uno sketch blink, ed è sulla mia lista.

Se lo costruisci, cambi il generatore del tracciato, o riesci a far scrivere il gioco a un modello, apri una issue sul repository. Le leggo tutte.


Condividi su:
David Montero

Scritto da

David Montero

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

GitHub velxio.dev

Articoli correlati


Articolo precedente
Le nuove schede XIAO IPS Display di Seeed, in esecuzione nel tuo browser pochi giorni dopo il lancio
Articolo successivo
I chip personalizzati crescono — un unico editor, slider per sensori dal vivo, una libreria personale di chip