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 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:
- Uma estrada feita de segmentos com curvas, colinas e depressões, gerada aleatoriamente a cada inicialização.
- Um carro do jogador 3D texturizado carregado de uma malha OBJ: 428 vértices, 312 triângulos, uma textura de 128 por 128.
- Prédios em ambos os lados da estrada, um longo túnel com luzes no teto, e árvores, arbustos, pedras e postes de luz nos espaços.
- Seis carros de tráfego com sua própria velocidade e faixa.
- Um ciclo de dia, pôr do sol e noite com névoa exponencial em direção ao horizonte.
- Física com rampas de aceleração, atrito, gravidade de colina e deriva lateral em curvas, além de colisões com tráfego, paredes e objetos à beira da estrada.
- Um HUD com um velocímetro redondo, contador de voltas e tempos de volta atual e melhor.
O hardware é deliberadamente comum:
| Componente | Detalhes |
|---|---|
| SoC | ESP32-S3, 240 MHz dual-core, com PSRAM |
| Display | ILI9341 TFT, 320 por 240 pixels, RGB565 de 16 bits, via SPI |
| Pinos do display | SCK 12, MOSI 11, MISO 13, CS 10, DC 9, RST 8 |
| Backlight | GPIO 39 |
| Botões | GPIO 17 (esquerda) e GPIO 16 (direita), com pull-ups internos |

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.
- 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 emrClip[n]a linha de tela mais baixa que ainda está visível naquela distância. À medida que o loop avança,maxysó 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. - 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]erCache[n-1], recortadas porrClip. 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 é.

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:
- 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.
- 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. - 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.
- 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.
- 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.

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/:
car_game_wrapper.cppsimplesmente faz#include "../car_game.ino", então o sketch se torna uma unidade de tradução C++ comum sem edições.Arduino.heArduino.cppfornecemmillis(),delay(),random(), umSerialque imprime em stdout, e umdigitalRead()que retorna o estado das teclas de seta para os dois pinos dos botões.TFT_eSPI.cppimplementaTFT_eSpritesobre uma render texture do Raylib.fillRect,fillTriangle,drawPixele o resto se tornam chamadas de desenho do Raylib;pushSprite()blita a textura para a janela.
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:
| Buffer | Tamanho | Onde |
|---|---|---|
| Sprite de quadro, 320 por 240 por 16 bits | 153.600 B | PSRAM |
| Sprite de céu e skyline, 640 por 120 por 16 bits | 153.600 B | PSRAM |
| Textura do carro, 128 por 128 por 16 bits | 32.768 B | Flash |
| Malha do carro, 428 vértices e 312 triângulos | cerca de 10 KB | Flash |
| Pista, 200 segmentos | cerca de 6 KB | SRAM interna |
| Caches de projeção e arrays por quadro | alguns KB | SRAM 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.

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:
- ChatGPT produziu código que não compilava, e depois de corrigido à mão seus cálculos de perspectiva estavam muito errados. Seu conselho consistente era adicionar um Z-buffer.
- Claude produziu a melhor estrutura de arquivos e o código mais legível, e então quebrou o pipeline de renderização. Ele não manteve a ordem de trás para frente entre estrada, túnel e prédios, e introduziu bugs sutis na matemática de ponto flutuante.
- Gemini 3 Pro chegou mais perto na matemática de projeção, e então não tinha opinião alguma sobre o resto. As cores pareciam erradas, a física parecia flutuante, e quando sua própria saída tinha artefatos de renderização ele não conseguia encontrá-los.
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:
- Ordenação no espaço. Saber o que está na frente do quê, em uma colina, dentro de um túnel, é um fato espacial, não textual.
- Cuidado numérico. O arquivo OBJ é Y-up e a tela é Y-down, o eixo V da textura vai de baixo para cima, e o deslocamento da curva é acumulado em ponto flutuante ao longo de quarenta segmentos a cada quadro. Cada um desses é um sinal ou um arredondamento de distância de uma estrada que deriva ou de um carro desenhado de dentro para fora.
- Gosto. Se 0,18 ou 0,25 é a constante centrífuga certa não pode ser derivado. Tem que ser pilotado.
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:
- 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.
- Instale a biblioteca TFT_eSPI e defina os mesmos pinos e o clock do SPI em
seu
User_Setup.h. - Abra
car_game.inona 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.