Voltar

Construindo um jogo de corrida 3D estilo OutRun para o ESP32-S3 sem um Z-buffer

Em fevereiro de 2026, entrei em uma discussão no Reddit. Alguém afirmava que os modelos de codificação mais recentes escreviam código melhor do que qualquer desenvolvedor humano, e citava os lançamentos mais novos da OpenAI e da Anthropic como prova. Eu não concordei. Os modelos são muito bons em landing pages, back ends CRUD e o tipo de demo que tem mil cópias no GitHub. Eu tinha dúvidas sobre o que acontece quando a tarefa exige raciocínio espacial, aritmética cuidadosa de ponto flutuante e uma opinião sobre o que é gostoso de jogar.

Então escolhi um projeto que quase não existe na internet e o construí eu mesmo: um jogo de corrida pseudo-3D no estilo OutRun, rodando em um ESP32-S3 com um display ILI9341. Ao longo do caminho, pedi aos modelos que construíssem a mesma coisa. Este artigo explica como o jogo funciona, com mais profundidade do que o texto mais curto que publiquei no Medium, e termina com o que os modelos erraram. O código-fonte completo está em github.com/davidmonterocrespo24/esp32s3-arcade-3d.

O jogo rodando no ESP32-S3 e em um display ILI9341: a tela inicial com o carro 3D girando, depois a corrida com a estrada, prédios, tráfego e o velocímetro

O que o jogo faz

A coisa toda tem cerca de 2.500 linhas de C++ no framework Arduino, desenhando através da biblioteca TFT_eSPI. Ele vem com:

O hardware é deliberadamente comum:

ComponenteDetalhes
SoCESP32-S3, 240 MHz dual-core, com PSRAM
DisplayILI9341 TFT, 320 por 240 pixels, RGB565 de 16 bits, via SPI
Pinos do displaySCK 12, MOSI 11, MISO 13, CS 10, DC 9, RST 8
BacklightGPIO 39
BotõesGPIO 17 (esquerda) e GPIO 16 (direita), com pull-ups internos

O protótipo: um módulo ESP32-S3 em uma placa de prototipagem, ligado a uma placa de display ILI9341 vermelha, segurado na mão

Por que não há Z-buffer

A primeira resposta que tanto o ChatGPT quanto o Claude me deram foi que o projeto não era viável nesse hardware, porque um renderizador 3D precisa de um buffer de profundidade e o microcontrolador não tem espaço para um. A aritmética vale a pena ser feita, porque mostra qual é a restrição real.

Um quadro de 320 por 240 em RGB565 tem 153.600 bytes. Um buffer de profundidade de 16 bits do mesmo tamanho é outros 153.600 bytes. O ESP32-S3 tem 512 KB de SRAM interna, da qual uma boa parte é tomada pelo core do Arduino, pelo driver do display e pelo heap, então dois buffers de tela cheia não cabem confortavelmente na memória interna. Com PSRAM eles cabem. O argumento da memória é fraco.

O argumento mais forte é o custo por pixel. Um buffer de profundidade significa uma leitura, uma comparação e uma escrita condicional para cada pixel de cada triângulo, em software, em um core que também precisa preencher 76.800 pixels por quadro e enviá-los via SPI. A 240 MHz esse orçamento é apertado.

Os corredores de arcade dos anos 1980 não tinham nem a memória nem a taxa de preenchimento, e resolveram isso com ordenação em vez de teste de profundidade: desenhe as coisas distantes primeiro e deixe as coisas próximas pintarem por cima delas. Esse é o algoritmo do pintor, e funciona porque uma estrada é uma cena muito bem comportada. Os segmentos já estão ordenados por distância, e todo o resto (árvores, prédios, tráfego) está anexado a um segmento. O único objeto que precisa de 3D real é o carro do jogador, e ele fica a uma distância fixa na frente da câmera, então pode ser ordenado por conta própria.

A estrada: segmentos e projeção

A estrada é um array de 200 segmentos, cada um com 200 unidades de mundo de comprimento. Um segmento armazena sua curvatura, sua elevação, um sprite opcional à beira da estrada, uma flag de túnel e a altura e cor de um prédio em 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
};

A escala do mundo vem da largura da estrada: ROAD_W é 2000 unidades para metade da estrada, que eu considero ser cerca de 10,5 m, então uma unidade é cerca de 5,25 mm. Todas as outras constantes em config.h são expressas nessas unidades.

A técnica central segue os artigos do JavaScript racer do Jake Gordon, que são a explicação mais clara de estradas pseudo-3D que eu conheço. A câmera fica CAM_HEIGHT unidades acima da estrada. O campo de visão define uma profundidade de câmera:

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

Projetar uma borda de segmento é então uma divisão e duas multiplicações. camZ1 é a distância da câmera até a borda próxima do segmento n, e sc1 é a escala nessa distância:

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

Curvas são o truque clássico: a curve de um segmento não é um ângulo, é uma mudança por segmento no deslocamento horizontal. À medida que o loop se afasta da câmera, ele acumula curveDX += seg.curve e curveX += curveDX, então o deslocamento cresce quadraticamente com a distância e a estrada se curva suavemente. Colinas são ainda mais simples: cada segmento tem seu próprio y, e a projeção usa a diferença de altura em relação à câmera, então uma estrada que sobe escala a tela e uma depressão desaparece abaixo do horizonte.

O detalhe interessante é que drawRoad() faz duas passagens sobre os segmentos, em direções opostas.

  1. Da frente para trás, projetando. O primeiro loop percorre do segmento mais próximo para fora, projeta cada borda em rCache[n], e registra em rClip[n] a linha de tela mais baixa que ainda está visível naquela distância. À medida que o loop avança, maxy só se move para cima: um segmento atrás de um cume de colina projeta abaixo do cume e é recortado. É assim que as colinas ocluem o que está atrás delas sem nenhuma comparação de profundidade.
  2. De trás para frente, desenhando. O segundo loop percorre da extremidade distante em direção à câmera e desenha a grama, as faixas de rumbling, a estrada e as linhas de faixa de cada segmento como faixas horizontais entre rCache[n] e rCache[n-1], recortadas por rClip. Perto da câmera um segmento pode ter muitas linhas de altura, então ele é subdividido em até três faixas com a largura interpolada entre as duas bordas, o que evita que as curvas pareçam trapézios empilhados.

Prédios e o túnel compartilham o loop

Uma versão inicial desenhava a estrada em um loop e os prédios em outro. Parecia certo em terreno plano e desmoronava em colinas: um prédio que deveria estar escondido atrás de um cume era pintado por cima dele, porque o segundo loop não tinha ideia do que o primeiro já havia desenhado.

A correção foi desenhar tudo o que pertence a um segmento dentro do mesmo loop de trás para frente. Para cada segmento, em ordem: as paredes e o teto do túnel se o segmento estiver dentro do túnel, os prédios se estiver fora, depois a superfície da estrada. Um prédio é um par de quads (face frontal e face lateral) construídos a partir das bordas projetadas da estrada do segmento e do 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);
}

O túnel é a mesma ideia com os quads virados para dentro: um quad de teto a uma altura fixa acima da estrada, dois quads de parede, uma luz amarela a cada quatro segmentos e um portal cinza desenhado no primeiro segmento. Como tudo é emitido em ordem de pintor, uma entrada de túnel no meio de uma colina parece correta sem casos especiais.

Sprites à beira da estrada e os carros de tráfego vão em um terceiro loop curto depois que a estrada está completa. Eles são desenhados como formas planas escaladas pela escala de projeção do segmento e recortados por rClip, então uma árvore atrás de uma colina é cortada no cume exatamente como a estrada é.

Dentro do túnel à noite: luzes no teto, paredes azuis, o carro do jogador e o velocímetro

O carro do jogador é 3D de verdade

Eu não queria um sprite para o jogador. O carro é uma malha OBJ convertida offline em um header C por um pequeno script Python, então nada é analisado em tempo de execução:

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

O header contém 428 vértices com posição e coordenadas de textura, e 312 triângulos como uma lista de índices. A textura de 128 por 128 tem 32 KB de RGB565 na flash, lida com pgm_read_word() para que nunca toque a RAM.

A cada quadro a malha passa por um pequeno pipeline fixo em render_player.cpp:

  1. Transformar. Cada vértice é rotacionado em torno do eixo vertical por um ângulo derivado da posição lateral do carro (o carro visivelmente vira para dentro da curva), depois inclinado pela inclinação da estrada, calculada em média sobre os próximos seis segmentos e suavizada ao longo do tempo para que o carro se incline em colinas sem tremer.
  2. Projetar. x = centerX + rx * fov / z, com uma distância de câmera fixa e uma distância focal de 130 pixels. Vértices atrás da câmera são sinalizados e seus triângulos ignorados.
  3. Ordenar. A profundidade média de cada triângulo é calculada e os 312 triângulos são ordenados do mais distante para o mais próximo. Um insertion sort é suficiente nesse tamanho e é quase gratuito quando a ordem mal muda entre quadros.
  4. Descartar e iluminar. Triângulos voltados para longe são descartados usando o sinal do produto vetorial 2D. A normal da face é calculada no espaço do objeto e multiplicada escalarmente por uma luz fixa vinda de cima e ligeiramente à frente, dando um brilho entre 0,35 e 1,0.
  5. Rasterizar. Cada triângulo é desenhado por um rasterizador de scanline com mapeamento de textura afim.

O rasterizador é a parte em que passei mais tempo. Ele ordena os três vértices por Y de tela, percorre as linhas entre o topo e a base, e para cada linha interpola o X esquerdo e direito e as coordenadas de textura ao longo das duas bordas ativas. Depois avança pela linha, amostra a textura, aplica a iluminação e escreve um 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);
}

O mapeamento afim interpola u e v linearmente no espaço de tela, o que não é correto em perspectiva. Em uma parede grande, produz a oscilação pela qual os jogos de PlayStation ficaram famosos. Em um carro que ocupa cerca de um quinto da tela, sempre à mesma distância, o erro fica abaixo de um pixel e a divisão por pixel que um mapeamento correto exigiria não vale a pena pagar. O renderizador também recusa qualquer scanline com mais de 160 pixels e qualquer triângulo com um vértice mais de 20 pixels fora da tela. Ambas as proteções existem por causa de bugs reais: um único vértice mal ordenado ou recortado se transforma em um rastro por todo o quadro, e é muito mais barato descartar o triângulo do que recortá-lo corretamente.

Sob o carro fica uma elipse escura desenhada como uma pilha de linhas horizontais. Ela se desloca com a posição lateral do carro e é a coisa mais barata do projeto com o maior efeito sobre o quão ancorado o carro parece.

O carro do jogador derrapando em uma curva ao pôr do sol passando por prédios, com o velocímetro marcando 262 km/h

Céu, paralaxe e a hora do dia

O céu não é desenhado a cada quadro. Na inicialização, initBackground() constrói um sprite de 640 por 120 na PSRAM (o dobro da largura da tela, com a altura da metade superior da tela) com um gradiente vertical, um sol e duas camadas de um skyline procedural com janelas iluminadas. A cada quadro o loop principal o rola por uma quantidade proporcional à curva e velocidade atuais, e o blita duas vezes para que a continuidade seja perfeita:

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

Esse é o efeito que Horizon Chase usa: o horizonte desliza lateralmente conforme você vira, o que vende a curva muito mais do que a geometria da estrada sozinha.

A paleta fica em colors.cpp como três conjuntos de cores RGB565 para dia, pôr do sol e noite: céu, grama clara e escura, estrada clara e escura, faixas de rumbling, marcações de faixa e névoa. O jogo muda para o próximo conjunto a cada 180.000 unidades de mundo percorridas, o que na velocidade máxima é cerca de quatorze segundos. A névoa é exponencial em distância e misturada por segmento:

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

lerpCol() mistura dois valores RGB565 canal por canal, o que é suficiente para desvanecer a estrada e a grama na cor da névoa sem nunca converter para 24 bits.

Física: o que faz parecer um carro

Os gráficos fazem um corredor ser notado. A física decide se alguém vai jogá-lo por mais de um minuto. Todo o ajuste fica em 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

A atualização em physics.cpp faz quatro coisas em ordem. A inclinação é lida da diferença de altura entre o segmento atual e o anterior e transformada em uma aceleração, então subidas drenam velocidade e descidas a adicionam. O atrito é aplicado como um decaimento multiplicativo. A aceleração, que aumenta ao longo do tempo em vez de saltar para seu alvo, é integrada na velocidade. Então a curva empurra o carro 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;

A velocidade lateral é o que dá a deriva. Um carro entrando em uma curva não desliza imediatamente; a força se acumula, o deslizamento cresce, e quando a curva termina o amortecimento traz o carro de volta. O ângulo da deriva é então calculado a partir da razão entre velocidade lateral e velocidade para frente e usado apenas para exibição.

As colisões são tratadas por distância ao longo da pista mais sobreposição no eixo lateral. Bater em um carro de tráfego mais lento por trás derruba você para setenta por cento da velocidade dele; tocar uma parede de túnel, uma árvore ou sair da estrada por completo reduz mais velocidade, e qualquer um deles acima de um limiar aciona o estado de batida de dois segundos.

A build no repositório roda como uma demo: um piloto automático lê a curva que se aproxima e contra-esterça nela, e o acelerador está sempre no fundo. Os dois botões são declarados e configurados com pull-up em setup(), e o emulador de PC mapeia as teclas de seta para eles, então a direção manual é algumas linhas em handleInput().

Nenhuma dessas constantes veio de uma fórmula. Eu esbocei a perspectiva no papel, construí as curvas de velocidade em uma planilha, e então mudei um número de cada vez até o carro parecer certo. Esse loop só funcionou porque cada iteração levava segundos, o que me leva ao emulador.

Desenvolvendo em um PC com Raylib

Gravar um ESP32-S3 leva tempo suficiente para que ajustar uma constante por sensação seja doloroso. Então o mesmo código-fonte compila no Windows contra Raylib, com as chamadas do Arduino e do TFT_eSPI substituídas por uma camada fina na pasta emulator/:

Um exemplo do tipo de coisa que a camada precisa acertar:

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

O TFT_eSPI não se importa com a ordem de winding, o Raylib se importa, e o código da estrada nunca promete uma. Desenhar cada um duas vezes não custa nada em uma GPU e tornou o emulador fiel em pixels à placa.

Com isso no lugar, o loop era editar, make, executar, em poucos segundos. As capturas de tela neste artigo são do emulador; a animação no topo é a placa.

Memória e orçamento de quadro na placa

Para onde vão os bytes:

BufferTamanhoOnde
Sprite de quadro, 320 por 240 por 16 bits153.600 BPSRAM
Sprite de céu e skyline, 640 por 120 por 16 bits153.600 BPSRAM
Textura do carro, 128 por 128 por 16 bits32.768 BFlash
Malha do carro, 428 vértices e 312 triânguloscerca de 10 KBFlash
Pista, 200 segmentoscerca de 6 KBSRAM interna
Caches de projeção e arrays por quadroalguns KBSRAM interna

Os dois sprites são criados com spr.setAttribute(PSRAM_ENABLE, true) antes de createSprite(). Essa única linha é a diferença entre um jogo que roda e um que falha ao alocar. Os 8 MB de PSRAM no módulo são em sua maioria não usados; o que importa é que os dois buffers de largura total não competem com o core do Arduino pela memória interna.

O quadro é composto inteiramente no sprite e enviado ao painel uma vez por loop com spr.pushSprite(0, 0). É isso que remove o tearing, e também é o teto da taxa de quadros: 153.600 bytes via SPI a 40 MHz levam cerca de 31 ms por si só, antes de qualquer desenho. Na placa o jogo se estabiliza em torno de 30 quadros por segundo, que é exatamente o que essa aritmética prevê. Aumentar o clock do SPI no User_Setup.h do TFT_eSPI é o primeiro lugar a olhar se você quiser mais.

Dia em uma reta com prédios em ambos os lados e um carro de tráfego amarelo à frente

Onde os modelos de IA falharam

Em paralelo com a versão escrita à mão, pedi aos modelos de codificação disponíveis em fevereiro de 2026 que construíssem o mesmo jogo a partir de uma descrição. Isto é o que aconteceu, da forma mais justa que consigo colocar:

Não acho que nada disso seja surpreendente. As três coisas que o projeto mais precisava são as três coisas que um modelo de linguagem tem menos:

Eu usei IA neste projeto, e foi útil exatamente para as coisas que ela faz bem: montar o layout dos módulos, escrever os conversores de OBJ e PNG, e refatorar código de desenho repetitivo depois que o design estava definido. Cada constante, cada projeção e cada decisão de ordenação foi tomada à mão. O modelo foi uma ferramenta, não o arquiteto. Isso era verdade em fevereiro de 2026 e pode não ser verdade para sempre; se você conseguir que um modelo produza uma versão jogável disto apenas a partir da descrição, eu gostaria genuinamente de ver.

Construa você mesmo

O repositório é github.com/davidmonterocrespo24/esp32s3-arcade-3d.

Na placa:

  1. Ligue um ILI9341 ao ESP32-S3 nos pinos da tabela de hardware acima, e os dois botões de pressão entre GPIO 17, GPIO 16 e o terra.
  2. Instale a biblioteca TFT_eSPI e defina os mesmos pinos e o clock do SPI em seu User_Setup.h.
  3. Abra car_game.ino na Arduino IDE, selecione ESP32S3 Dev Module, habilite a PSRAM nas opções da placa, e faça o upload.

Em um PC (Windows, MinGW e Raylib instalados):

cd emulator/
make
./car_game_emu.exe

A mesma fiação é descrita no diagram.json do repositório, e tanto o ESP32-S3 DevKit quanto o ILI9341 existem no catálogo de peças da Velxio se você quiser montar o circuito sem um ferro de solda. Ainda não tentei rodar o jogo completo dentro da placa emulada; a configuração de pinos do TFT_eSPI e o sprite na PSRAM tornam isso um teste mais interessante do que um blink sketch, e está na minha lista.

Se você construí-lo, mudar o gerador de pista, ou conseguir fazer um modelo escrevê-lo, abra uma issue no repositório. Eu leio todas.


Compartilhar em:
David Montero

Escrito por

David Montero

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

GitHub velxio.dev

Artigos relacionados


Artigo anterior
As novas placas XIAO IPS Display da Seeed, rodando no seu navegador dias após o lançamento
Próximo artigo
Chips personalizados evoluem: um editor, sliders de sensores ao vivo e uma biblioteca pessoal de chips