Zurück

Ein 3D-Rennspiel im OutRun-Stil für den ESP32-S3 bauen — ohne Z-Buffer

Im Februar 2026 geriet ich auf Reddit in eine Diskussion. Jemand behauptete, die neuesten Coding-Modelle würden besseren Code schreiben als jeder menschliche Entwickler, und führte die neuesten Releases von OpenAI und Anthropic als Beweis an. Ich war anderer Meinung. Die Modelle sind sehr gut bei Landing Pages, CRUD-Backends und der Art von Demo, von der es tausend Kopien auf GitHub gibt. Ich hatte Zweifel daran, was passiert, wenn die Aufgabe räumliches Denken, sorgfältige Gleitkomma-Arithmetik und eine Meinung dazu erfordert, was sich gut spielt.

Also suchte ich mir ein Projekt aus, das im Internet kaum existiert, und baute es selbst: ein Pseudo-3D-Rennspiel im Stil von OutRun, das auf einem ESP32-S3 mit einem ILI9341-Display läuft. Unterwegs bat ich die Modelle, dasselbe zu bauen. Dieser Artikel erklärt, wie das Spiel funktioniert — ausführlicher als die kürzere Beschreibung, die ich auf Medium veröffentlicht habe — und endet damit, was die Modelle falsch gemacht haben. Der vollständige Quellcode liegt unter github.com/davidmonterocrespo24/esp32s3-arcade-3d.

Das Spiel läuft auf dem ESP32-S3 und einem ILI9341-Display: der Startbildschirm mit dem rotierenden 3D-Auto, dann das Rennen mit Straße, Gebäuden, Verkehr und dem Tacho

Was das Spiel macht

Das Ganze sind etwa 2.500 Zeilen C++ im Arduino-Framework, gezeichnet über die TFT_eSPI-Bibliothek. Es bietet:

Die Hardware ist bewusst gewöhnlich:

KomponenteDetails
SoCESP32-S3, 240 MHz Dual-Core, mit PSRAM
DisplayILI9341 TFT, 320 mal 240 Pixel, 16-Bit RGB565, über SPI
Display-PinsSCK 12, MOSI 11, MISO 13, CS 10, DC 9, RST 8
HintergrundbeleuchtungGPIO 39
TastenGPIO 17 (links) und GPIO 16 (rechts), mit internen Pull-ups

Der Prototyp: ein ESP32-S3-Modul auf einem Lochraster, verdrahtet mit einem roten ILI9341-Displayboard, in einer Hand gehalten

Warum es keinen Z-Buffer gibt

Die erste Antwort, die mir sowohl ChatGPT als auch Claude gaben, war, dass das Projekt auf dieser Hardware nicht machbar sei, weil ein 3D-Renderer einen Tiefenpuffer braucht und der Mikrocontroller keinen Platz dafür hat. Die Rechnung lohnt sich, denn sie zeigt, wo die eigentliche Einschränkung liegt.

Ein Frame von 320 mal 240 in RGB565 sind 153.600 Bytes. Ein 16-Bit-Tiefenpuffer derselben Größe sind weitere 153.600 Bytes. Der ESP32-S3 hat 512 KB internen SRAM, von dem ein guter Teil vom Arduino-Core, dem Display-Treiber und dem Heap belegt wird, sodass zwei Vollbild-Puffer nicht bequem in den internen Speicher passen. Mit PSRAM passen sie hinein. Das Speicherargument ist schwach.

Das stärkere Argument sind die Kosten pro Pixel. Ein Tiefenpuffer bedeutet pro Pixel jedes Dreiecks ein Lesen, ein Vergleichen und ein bedingtes Schreiben, in Software, auf einem Kern, der außerdem 76.800 Pixel pro Frame füllen und über SPI ausgeben muss. Bei 240 MHz ist dieses Budget knapp.

Arcade-Racer der 1980er hatten weder den Speicher noch die Füllrate und lösten es mit Reihenfolge statt Tiefentest: Zeichne die fernen Dinge zuerst und lass die nahen darüber malen. Das ist der Painter’s Algorithm, und er funktioniert, weil eine Straße eine sehr wohlerzogene Szene ist. Die Segmente sind bereits nach Entfernung sortiert, und alles andere (Bäume, Gebäude, Verkehr) hängt an einem Segment. Das einzige Objekt, das echtes 3D braucht, ist das Auto des Spielers, und es sitzt in fester Entfernung vor der Kamera, kann also für sich allein sortiert werden.

Die Straße: Segmente und Projektion

Die Straße ist ein Array aus 200 Segmenten, jedes 200 Welteinheiten lang. Ein Segment speichert seine Krümmung, seine Höhe, ein optionales Sprite am Straßenrand, ein Tunnel-Flag und die Höhe und Farbe eines Gebäudes auf jeder Seite:

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

Der Weltmaßstab ergibt sich aus der Straßenbreite: ROAD_W ist 2000 Einheiten für die halbe Straße, was ich mit etwa 10,5 m ansetze, sodass eine Einheit etwa 5,25 mm entspricht. Jede andere Konstante in config.h ist in diesen Einheiten ausgedrückt.

Die Kerntechnik folgt Jake Gordons JavaScript-Racer-Artikeln, die die klarste Erklärung von Pseudo-3D-Straßen sind, die ich kenne. Die Kamera sitzt CAM_HEIGHT Einheiten über der Straße. Das Sichtfeld definiert eine Kameratiefe:

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

Eine Segmentkante zu projizieren ist dann eine Division und zwei Multiplikationen. camZ1 ist die Entfernung von der Kamera zur nahen Kante von Segment n, und sc1 ist der Maßstab bei dieser Entfernung:

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

Kurven sind der klassische Trick: Die curve eines Segments ist kein Winkel, sondern eine Änderung des horizontalen Versatzes pro Segment. Während die Schleife sich von der Kamera entfernt, akkumuliert sie curveDX += seg.curve und curveX += curveDX, sodass der Versatz quadratisch mit der Entfernung wächst und die Straße sich sanft biegt. Hügel sind noch einfacher: Jedes Segment hat sein eigenes y, und die Projektion nutzt die Höhendifferenz zur Kamera, sodass eine ansteigende Straße den Bildschirm hinaufklettert und eine Senke unter dem Horizont verschwindet.

Das interessante Detail ist, dass drawRoad() zwei Durchläufe über die Segmente macht, in entgegengesetzten Richtungen.

  1. Von vorne nach hinten, projizieren. Die erste Schleife läuft vom nächsten Segment nach außen, projiziert jede Kante in rCache[n] und merkt sich in rClip[n] die niedrigste Bildschirmzeile, die in dieser Entfernung noch sichtbar ist. Während die Schleife voranschreitet, bewegt sich maxy nur nach oben: Ein Segment hinter einer Hügelkuppe projiziert unter die Kuppe und wird weggeschnitten. So verdecken Hügel das, was hinter ihnen liegt, ohne jeden Tiefenvergleich.
  2. Von hinten nach vorne, zeichnen. Die zweite Schleife läuft vom fernen Ende zur Kamera und zeichnet Gras, Randstreifen, Straße und Fahrbahnmarkierungen jedes Segments als horizontale Bänder zwischen rCache[n] und rCache[n-1], beschnitten durch rClip. Nahe der Kamera kann ein Segment viele Zeilen hoch sein, also wird es in bis zu drei Bänder unterteilt, wobei die Breite zwischen den beiden Kanten interpoliert wird, was verhindert, dass Kurven wie gestapelte Trapeze aussehen.

Gebäude und Tunnel teilen sich die Schleife

Eine frühe Version zeichnete die Straße in einer Schleife und die Gebäude in einer anderen. Auf flachem Boden sah es richtig aus und auf Hügeln zerfiel es: Ein Gebäude, das hinter einer Kuppe verborgen sein sollte, wurde darüber gemalt, weil die zweite Schleife keine Ahnung hatte, was die erste bereits gezeichnet hatte.

Die Lösung war, alles, was zu einem Segment gehört, innerhalb derselben Von-hinten-nach-vorne-Schleife zu zeichnen. Für jedes Segment, in dieser Reihenfolge: die Tunnelwände und die Decke, wenn das Segment im Tunnel liegt, die Gebäude, wenn es außerhalb liegt, dann die Straßenoberfläche. Ein Gebäude ist ein Paar von Quads (Vorder- und Seitenfläche), gebaut aus den projizierten Straßenkanten des Segments und des vorherigen:

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

Der Tunnel ist dieselbe Idee mit nach innen gedrehten Quads: ein Decken-Quad in fester Höhe über der Straße, zwei Wand-Quads, ein gelbes Licht alle vier Segmente und ein graues Portal, das auf dem ersten Segment gezeichnet wird. Weil alles in Painter-Reihenfolge ausgegeben wird, sieht ein Tunneleingang auf halber Höhe eines Hügels ohne Sonderfälle korrekt aus.

Sprites am Straßenrand und die Verkehrsautos kommen in eine dritte, kurze Schleife, nachdem die Straße fertig ist. Sie werden als flache Formen gezeichnet, skaliert mit dem Projektionsmaßstab des Segments und beschnitten auf rClip, sodass ein Baum hinter einem Hügel genau an der Kuppe abgeschnitten wird, genau wie die Straße.

Nachts im Tunnel: Deckenlichter, blaue Wände, das Spielerauto und der Tacho

Das Spielerauto ist echtes 3D

Ich wollte kein Sprite für den Spieler. Das Auto ist ein OBJ-Mesh, das offline von einem kleinen Python-Skript in einen C-Header umgewandelt wird, sodass zur Laufzeit nichts geparst wird:

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

Der Header enthält 428 Vertices mit Position und Texturkoordinaten sowie 312 Dreiecke als Indexliste. Die 128 mal 128 große Textur sind 32 KB RGB565 im Flash, gelesen mit pgm_read_word(), sodass sie nie den RAM berührt.

Jeden Frame durchläuft das Mesh eine kleine feste Pipeline in render_player.cpp:

  1. Transformieren. Jeder Vertex wird um die vertikale Achse gedreht, um einen Winkel, der aus der seitlichen Position des Autos abgeleitet wird (das Auto dreht sich sichtbar in die Kurve hinein), dann um die Straßensteigung geneigt, gemittelt über die nächsten sechs Segmente und über die Zeit geglättet, damit das Auto auf Hügeln kippt, ohne zu zittern.
  2. Projizieren. x = centerX + rx * fov / z, mit fester Kameraentfernung und einer Brennweite von 130 Pixeln. Vertices hinter der Kamera werden markiert und ihre Dreiecke übersprungen.
  3. Sortieren. Die durchschnittliche Tiefe jedes Dreiecks wird berechnet und die 312 Dreiecke werden von fern nach nah sortiert. Ein Insertion Sort reicht bei dieser Größe aus und ist nahezu kostenlos, wenn sich die Reihenfolge zwischen Frames kaum ändert.
  4. Culling und Beleuchtung. Abgewandte Dreiecke werden anhand des Vorzeichens des 2D-Kreuzprodukts verworfen. Die Flächennormale wird im Objektraum berechnet und mit einem festen Licht von oben und leicht von vorne skalar multipliziert, was eine Helligkeit zwischen 0,35 und 1,0 ergibt.
  5. Rasterisieren. Jedes Dreieck wird von einem Scanline-Rasterizer mit affinem Texture-Mapping gezeichnet.

Der Rasterizer ist der Teil, an dem ich am meisten Zeit verbracht habe. Er sortiert die drei Vertices nach Bildschirm-Y, läuft durch die Zeilen zwischen oben und unten und interpoliert für jede Zeile das linke und rechte X sowie die Texturkoordinaten entlang der beiden aktiven Kanten. Dann läuft er über die Zeile, tastet die Textur ab, wendet die Beleuchtung an und schreibt ein 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);
}

Affines Mapping interpoliert u und v linear im Bildschirmraum, was nicht perspektivisch korrekt ist. Auf einer großen Wand erzeugt es das Wackeln, für das PlayStation-Spiele berühmt waren. Auf einem Auto, das etwa ein Fünftel des Bildschirms einnimmt, immer in derselben Entfernung, liegt der Fehler unter einem Pixel, und die Division pro Pixel, die ein korrektes Mapping bräuchte, ist es nicht wert. Der Renderer lehnt außerdem jede Scanline ab, die breiter als 160 Pixel ist, und jedes Dreieck mit einem Vertex, das mehr als 20 Pixel außerhalb des Bildschirms liegt. Beide Schutzmaßnahmen existieren wegen echter Bugs: Ein einzelner falsch sortierter oder beschnittener Vertex wird zu einem Streifen über den ganzen Frame, und es ist weit billiger, das Dreieck zu verwerfen, als es ordentlich zu beschneiden.

Unter dem Auto liegt eine dunkle Ellipse, gezeichnet als Stapel horizontaler Linien. Sie verschiebt sich mit der seitlichen Position des Autos und ist das billigste Element im ganzen Projekt mit der größten Wirkung darauf, wie geerdet das Auto aussieht.

Das Spielerauto driftet durch eine Sonnenuntergangskurve an Gebäuden vorbei, der Tacho zeigt 262 km/h

Himmel, Parallaxe und Tageszeit

Der Himmel wird nicht jeden Frame gezeichnet. Beim Start baut initBackground() ein 640 mal 120 großes Sprite im PSRAM (doppelte Bildschirmbreite, halbe Bildschirmhöhe) mit einem vertikalen Farbverlauf, einer Sonne und zwei Schichten einer prozeduralen Skyline mit beleuchteten Fenstern. Jeden Frame scrollt die Hauptschleife es um einen Betrag, der proportional zur aktuellen Kurve und Geschwindigkeit ist, und blittet es zweimal, damit der Umbruch nahtlos ist:

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

Das ist der Effekt, den Horizon Chase verwendet: Der Horizont gleitet seitwärts, während man lenkt, was die Kurve weit mehr verkauft als die Straßengeometrie allein.

Die Palette liegt in colors.cpp als drei Sätze von RGB565-Farben für Tag, Sonnenuntergang und Nacht: Himmel, helles und dunkles Gras, helle und dunkle Straße, Randstreifen, Fahrbahnmarkierungen und Nebel. Das Spiel wechselt alle 180.000 Welteinheiten Fahrt zum nächsten Satz, was bei Höchstgeschwindigkeit etwa vierzehn Sekunden sind. Der Nebel ist exponentiell in der Entfernung und wird pro Segment gemischt:

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

lerpCol() mischt zwei RGB565-Werte Kanal für Kanal, was ausreicht, um Straße und Gras in die Nebelfarbe übergehen zu lassen, ohne je nach 24-Bit zu konvertieren.

Physik: Was sich wie ein Auto anfühlt

Grafik lässt einen Racer auffallen. Die Physik entscheidet, ob ihn jemand länger als eine Minute spielt. Die gesamte Abstimmung liegt 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

Das Update in physics.cpp macht vier Dinge der Reihe nach. Die Steigung wird aus der Höhendifferenz zwischen dem aktuellen und dem vorherigen Segment gelesen und in eine Beschleunigung umgewandelt, sodass Anstiege Geschwindigkeit kosten und Gefälle sie hinzufügen. Die Reibung wird als multiplikativer Abfall angewendet. Die Beschleunigung, die über die Zeit ansteigt statt auf ihren Zielwert zu springen, wird in die Geschwindigkeit integriert. Dann drückt die Kurve das Auto seitwärts:

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;

Die seitliche Geschwindigkeit ist es, die den Drift erzeugt. Ein Auto, das in eine Kurve einfährt, rutscht nicht sofort; die Kraft sammelt sich an, das Rutschen baut sich auf, und wenn die Kurve endet, bringt die Dämpfung das Auto zurück. Der Winkel des Drifts wird dann aus dem Verhältnis von seitlicher zu vorwärts gerichteter Geschwindigkeit berechnet und nur zur Anzeige verwendet.

Kollisionen werden über die Entfernung entlang der Strecke plus Überlappung in der seitlichen Achse behandelt. Ein Auffahrunfall auf ein langsameres Verkehrsauto von hinten reduziert dich auf siebzig Prozent von dessen Geschwindigkeit; das Berühren einer Tunnelwand, eines Baums oder das Verlassen der Straße kostet mehr Geschwindigkeit, und alles davon über einem Schwellenwert löst den zwei Sekunden dauernden Crash-Zustand aus.

Der Build im Repository läuft als Demo: Ein Autopilot liest die kommende Kurve und lenkt gegen sie, und das Gas ist immer durchgedrückt. Die beiden Tasten werden in setup() deklariert und mit Pull-ups versehen, und der PC-Emulator bildet die Pfeiltasten darauf ab, sodass manuelles Lenken nur ein paar Zeilen in handleInput() sind.

Keine dieser Konstanten stammt aus einer Formel. Ich habe die Perspektive auf Papier skizziert, die Geschwindigkeitskurven in einer Tabellenkalkulation gebaut und dann eine Zahl nach der anderen geändert, bis sich das Auto richtig anfühlte. Diese Schleife funktionierte nur, weil jede Iteration Sekunden dauerte, was mich zum Emulator bringt.

Entwicklung auf einem PC mit Raylib

Das Flashen eines ESP32-S3 dauert lange genug, dass das Abstimmen einer Konstante nach Gefühl schmerzhaft ist. Also kompiliert derselbe Quellcode unter Windows gegen Raylib, wobei die Arduino- und TFT_eSPI-Aufrufe durch einen dünnen Shim im Ordner emulator/ ersetzt werden:

Ein Beispiel dafür, was der Shim richtig hinbekommen muss:

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

TFT_eSPI kümmert sich nicht um die Wicklungsreihenfolge, Raylib schon, und der Straßencode verspricht keine. Jedes Dreieck zweimal zu zeichnen kostet auf einer GPU nichts und machte den Emulator pixelgenau zum Board.

Damit war die Schleife Bearbeiten, make, Ausführen in wenigen Sekunden. Die Screenshots in diesem Artikel stammen aus dem Emulator; die Animation oben ist das Board.

Speicher und Frame-Budget auf dem Board

Wohin die Bytes gehen:

PufferGrößeWo
Frame-Sprite, 320 mal 240 mal 16-Bit153.600 BPSRAM
Himmel- und Skyline-Sprite, 640 mal 120 mal 16-Bit153.600 BPSRAM
Auto-Textur, 128 mal 128 mal 16-Bit32.768 BFlash
Auto-Mesh, 428 Vertices und 312 Dreieckeetwa 10 KBFlash
Strecke, 200 Segmenteetwa 6 KBInterner SRAM
Projektions-Caches und Per-Frame-Arraysein paar KBInterner SRAM

Die beiden Sprites werden mit spr.setAttribute(PSRAM_ENABLE, true) vor createSprite() erstellt. Diese eine Zeile ist der Unterschied zwischen einem Spiel, das läuft, und einem, das nicht allokieren kann. Die 8 MB PSRAM auf dem Modul bleiben größtenteils ungenutzt; entscheidend ist, dass die beiden vollbreiten Puffer nicht mit dem Arduino-Core um internen Speicher konkurrieren.

Der Frame wird vollständig im Sprite zusammengesetzt und einmal pro Schleife mit spr.pushSprite(0, 0) an das Panel geschickt. Das ist es, was Tearing beseitigt, und es ist zugleich die Obergrenze der Bildrate: 153.600 Bytes über SPI bei 40 MHz dauern allein etwa 31 ms, bevor überhaupt gezeichnet wird. Auf dem Board pendelt sich das Spiel bei etwa 30 Bildern pro Sekunde ein, was genau das ist, was diese Rechnung vorhersagt. Den SPI-Takt in TFT_eSPIs User_Setup.h zu erhöhen, ist die erste Stelle, an der man suchen sollte, wenn man mehr will.

Tagsüber auf einer Geraden mit Gebäuden auf beiden Seiten und einem gelben Verkehrsauto voraus

Wo die KI-Modelle versagten

Parallel zur handgeschriebenen Version bat ich die im Februar 2026 verfügbaren Coding-Modelle, dasselbe Spiel aus einer Beschreibung zu bauen. Das ist passiert, so fair ich es formulieren kann:

Ich halte nichts davon für überraschend. Die drei Dinge, die das Projekt am meisten brauchte, sind die drei Dinge, von denen ein Sprachmodell am wenigsten hat:

Ich habe bei diesem Projekt KI eingesetzt, und sie war genau für die Dinge nützlich, in denen sie gut ist: das Gerüst der Modulaufteilung, das Schreiben der OBJ- und PNG-Konverter und das Refactoring repetitiver Zeichenroutinen, sobald das Design stand. Jede Konstante, jede Projektion und jede Reihenfolgeentscheidung wurde von Hand getroffen. Das Modell war ein Werkzeug, nicht der Architekt. Das galt im Februar 2026 und muss nicht für immer gelten; wenn du ein Modell dazu bringst, allein aus der Beschreibung eine spielbare Version davon zu erzeugen, würde ich das wirklich gern sehen.

Bau es selbst

Das Repository ist github.com/davidmonterocrespo24/esp32s3-arcade-3d.

Auf dem Board:

  1. Verdrahte ein ILI9341 mit dem ESP32-S3 an den Pins aus der Hardware-Tabelle oben sowie die beiden Taster zwischen GPIO 17, GPIO 16 und Masse.
  2. Installiere die TFT_eSPI-Bibliothek und setze dieselben Pins und den SPI-Takt in ihrer User_Setup.h.
  3. Öffne car_game.ino in der Arduino IDE, wähle ESP32S3 Dev Module, aktiviere PSRAM in den Board-Optionen und lade hoch.

Auf einem PC (Windows, MinGW und Raylib installiert):

cd emulator/
make
./car_game_emu.exe

Dieselbe Verdrahtung ist in der diagram.json des Repositories beschrieben, und sowohl das ESP32-S3 DevKit als auch das ILI9341 existieren im Teilekatalog von Velxio, wenn du die Schaltung ohne Lötkolben auslegen möchtest. Ich habe noch nicht versucht, das vollständige Spiel im emulierten Board laufen zu lassen; TFT_eSPIs Pin-Setup und das PSRAM-Sprite machen es zu einem interessanteren Test als ein Blink-Sketch, und es steht auf meiner Liste.

Wenn du es baust, den Streckengenerator änderst oder es schaffst, ein Modell dazu zu bringen, es zu schreiben, öffne ein Issue im Repository. Ich lese alle.


Teilen auf:
David Montero

Geschrieben von

David Montero

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

GitHub velxio.dev

Ähnliche Artikel


Vorheriger Artikel
Seeeds neue XIAO IPS Display Boards, wenige Tage nach dem Launch im Browser
Nächster Artikel
Eigene Chips werden erwachsen — ein Editor, Live-Sensor-Slider, eine persönliche Chip-Bibliothek