戻る

ZバッファなしでESP32-S3向けのOutRun風3Dレーシングゲームを作る

2026年2月、私はRedditで言い争いになった。誰かが、最新のコーディングモデルはどんな人間の開発者よりも優れたコードを書くと主張し、その証拠としてOpenAIとAnthropicの最新リリースを挙げたのだ。私は同意しなかった。モデルはランディングページ、CRUDバックエンド、そしてGitHubに千個のコピーが転がっているようなデモの類は非常に得意だ。しかし、タスクが空間推論や注意深い浮動小数点演算、そして「遊んでいて気持ちいいかどうか」という意見を必要とするとき、何が起こるのかについては疑問があった。

そこで私は、インターネット上にほとんど存在しないプロジェクトを選び、自分で作ることにした。OutRun風の疑似3Dレーシングゲームを、ESP32-S3とILI9341ディスプレイで動かすのだ。その過程で、同じものをモデルにも作らせてみた。この記事では、ゲームがどのように動作するのかを、Mediumに公開した短い記事よりも詳しく説明し、最後にモデルがどこで間違えたのかを述べる。完全なソースはgithub.com/davidmonterocrespo24/esp32s3-arcade-3dにある。

ESP32-S3とILI9341ディスプレイで動作するゲーム。回転する3Dカーのスタート画面、そして道路、建物、交通、スピードメーターのあるレース

ゲームの内容

全体はArduinoフレームワーク上の約2,500行のC++で、TFT_eSPIライブラリを通して描画している。以下を備えている。

ハードウェアはあえて平凡なものにしている。

コンポーネント詳細
SoCESP32-S3、240 MHzデュアルコア、PSRAM付き
ディスプレイILI9341 TFT、320×240ピクセル、16ビットRGB565、SPI経由
ディスプレイピンSCK 12、MOSI 11、MISO 13、CS 10、DC 9、RST 8
バックライトGPIO 39
ボタンGPIO 17(左)とGPIO 16(右)、内部プルアップ付き

プロトタイプ。ユニバーサル基板上のESP32-S3モジュールが、赤いILI9341ディスプレイボードに配線され、手に持たれている

Zバッファが存在しない理由

ChatGPTとClaudeの両方が最初に返してきた答えは、このプロジェクトはこのハードウェアでは実現不可能だというものだった。3Dレンダラーには深度バッファが必要で、マイクロコントローラにはその余地がないからだ。実際の制約が何であるかを示すために、計算してみる価値がある。

RGB565の320×240フレームは153,600バイト。同じサイズの16ビット深度バッファはさらに153,600バイト。ESP32-S3には512 KBの内部SRAMがあり、そのかなりの部分がArduinoコア、ディスプレイドライバ、ヒープに取られるので、フルスクリーンバッファ2枚は内部メモリに余裕を持って収まらない。PSRAMを使えば収まる。メモリの議論は弱い。

より強い議論はピクセルあたりのコストだ。深度バッファとは、すべての三角形のすべてのピクセルに対して、読み取り、比較、条件付き書き込みをソフトウェアで行うことを意味する。しかもそのコアは1フレームあたり76,800ピクセルを埋めてSPIで送り出さなければならない。240 MHzではその予算は薄い。

1980年代のアーケードレーサーにはメモリもフィルレートもなく、彼らは深度テストの代わりに順序で解決した。遠いものを先に描き、近いもので上書きするのだ。これが画家のアルゴリズムであり、道路という非常に行儀の良いシーンだからこそ機能する。セグメントはすでに距離でソートされており、それ以外のもの(木、建物、交通)はセグメントに紐付いている。本当の3Dが必要な唯一のオブジェクトはプレイヤーの車で、これはカメラの前方の固定距離に位置するため、単独でソートできる。

道路 — セグメントと投影

道路は200個のセグメントの配列で、それぞれが200ワールド単位の長さを持つ。セグメントは曲率、標高、オプションの路側スプライト、トンネルフラグ、そして両側の建物の高さと色を格納する。

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

ワールドスケールは道路幅から決まる。ROAD_Wは道路の半分で2000単位、これを約10.5 mと見なすので、1単位は約5.25 mmとなる。config.hの他のすべての定数はこの単位で表現されている。

中核となるテクニックはJake GordonのJavaScript racerの記事に従っている。私が知る中で疑似3D道路の最も明快な解説だ。カメラは道路のCAM_HEIGHT単位上に位置する。視野角がカメラ深度を定義する。

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

セグメントの端を投影するのは、除算1回と乗算2回で済む。camZ1はカメラからセグメントnの手前側の端までの距離、sc1はその距離でのスケールだ。

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

カーブは古典的なトリックだ。セグメントのcurveは角度ではなく、セグメントごとの水平オフセットの変化量である。ループがカメラから遠ざかるにつれてcurveDX += seg.curveとcurveX += curveDXを累積するので、オフセットは距離に対して二次関数的に増大し、道路は滑らかに曲がる。丘はさらに単純で、各セグメントが独自のyを持ち、投影はカメラとの高さの差を使うので、上り坂は画面を登り、くぼみは地平線の下に消える。

興味深いのは、drawRoad()がセグメントに対して逆方向に2回のパスを行うことだ。

  1. 手前から奥へ、投影。 最初のループは最も近いセグメントから外側へ進み、各端をrCache[n]に投影し、その距離でまだ見えている最も低い画面行をrClip[n]に記録する。ループが進むにつれてmaxyは上にしか動かない。丘の頂上の後ろにあるセグメントは頂上の下に投影され、クリップされる。これが、深度比較なしに丘がその背後を隠す仕組みだ。
  2. 奥から手前へ、描画。 2番目のループは遠い端からカメラに向かって進み、各セグメントの草、ランブルストリップ、道路、車線をrCache[n]とrCache[n-1]の間の水平バンドとして描画し、rClipでクリップする。カメラの近くではセグメントが何行もの高さになることがあるので、最大3つのバンドに分割し、幅を両端の間で補間する。これによりカーブが積み重なった台形に見えるのを防ぐ。

建物とトンネルは同じループを共有する

初期のバージョンでは道路を1つのループで、建物を別のループで描画していた。平地では正しく見えたが、丘では崩れた。丘の頂上の後ろに隠れるべき建物がその上に描画されたのだ。2番目のループは最初のループが何を描いたかを知らなかったからだ。

修正は、セグメントに属するすべてのものを同じ奥から手前へのループ内で描画することだった。各セグメントについて、順に、セグメントがトンネル内ならトンネルの壁と天井、外なら建物、そして路面を描画する。建物は、そのセグメントと1つ前のセグメントの投影された道路端から作られる2つのクワッド(前面と側面)だ。

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

トンネルも同じ考えで、クワッドを内側に向けたものだ。道路の上に固定高さの天井クワッド、2つの壁クワッド、4セグメントごとの黄色い照明、最初のセグメントに描かれる灰色のポータル。すべてが画家の順序で出力されるので、丘の途中にあるトンネルの入り口も特別な処理なしで正しく見える。

路側スプライトと交通車両は、道路が完成した後の3番目の短いループに入る。それらはセグメントの投影スケールで拡大縮小された平らな図形として描画され、rClipでクリップされるので、丘の後ろの木は道路とまったく同じように頂上で切り取られる。

夜のトンネル内。天井灯、青い壁、プレイヤーカー、スピードメーター

プレイヤーカーは本物の3D

プレイヤーにスプライトは使いたくなかった。車は小さなPythonスクリプトでオフラインでCヘッダに変換されたOBJメッシュなので、実行時に何もパースされない。

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

ヘッダには位置とテクスチャ座標を持つ428頂点と、インデックスリストとしての312三角形が格納されている。128×128テクスチャはフラッシュ内の32 KBのRGB565で、pgm_read_word()で読み取られるのでRAMには一切触れない。

毎フレーム、メッシュはrender_player.cppの小さな固定パイプラインを通る。

  1. 変換。 各頂点は、車の横位置から導かれる角度で垂直軸周りに回転され(車は目に見えてカーブに曲がり込む)、次に道路の傾斜でピッチングされる。傾斜は次の6セグメントで平均化され、時間的に平滑化されるので、車は丘でジッターなく傾く。
  2. 投影。 x = centerX + rx * fov / z、固定のカメラ距離と130ピクセルの焦点距離を使う。カメラの後ろの頂点はフラグが立てられ、その三角形はスキップされる。
  3. ソート。 各三角形の平均深度が計算され、312三角形が遠い順から近い順にソートされる。このサイズでは挿入ソートで十分で、フレーム間で順序がほとんど変わらないときはほぼ無料だ。
  4. カリングとライティング。 裏を向いた三角形は2D外積の符号を使って除外される。面法線はオブジェクト空間で計算され、上方かつやや前方からの固定光源と内積を取ることで、0.35から1.0の間の明るさが得られる。
  5. ラスタライズ。 各三角形は、アフィンテクスチャマッピングを備えたスキャンラインラスタライザで描画される。

ラスタライザは私が最も時間を費やした部分だ。3つの頂点を画面Yでソートし、上端と下端の間の行を進み、各行について左右のXとテクスチャ座標を2つのアクティブエッジに沿って補間する。そして行を横切って進み、テクスチャをサンプリングし、ライティングを適用し、1ピクセルを書き込む。

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

アフィンマッピングはuとvを画面空間で線形に補間するので、透視補正はされていない。大きな壁では、PlayStationのゲームが有名だったあの揺らぎを生む。画面の約5分の1を占め、常に同じ距離にある車では、誤差は1ピクセル未満であり、正しいマッピングが必要とするピクセルあたりの除算を支払う価値はない。レンダラーはまた、160ピクセルより広いスキャンラインと、画面外に20ピクセル以上はみ出した頂点を持つ三角形を拒否する。どちらのガードも実際のバグから生まれた。ソートを誤った、あるいはクリップされた頂点が1つあるだけでフレーム全体に筋が走るので、適切にクリップするよりも三角形を捨てる方がはるかに安い。

車の下には、水平線を積み重ねて描かれた暗い楕円がある。これは車の横位置に応じて移動し、このプロジェクトで最も安価でありながら、車がどれだけ地面に接地して見えるかに最大の効果を持つものだ。

夕暮れのカーブを建物のそばでドリフトするプレイヤーカー。スピードメーターは262 km/hを示している

空、視差、時刻

空は毎フレーム描画されない。起動時にinitBackground()がPSRAM内に640×120のスプライト(画面幅の2倍、画面の上半分の高さ)を、垂直グラデーション、太陽、窓に明かりの灯った手続き的スカイラインの2層とともに構築する。毎フレーム、メインループは現在のカーブと速度に比例した量だけそれをスクロールし、ラップアラウンドがシームレスになるように2回ブリットする。

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

これはHorizon Chaseが使っている効果だ。曲がると地平線が横にスライドし、道路のジオメトリだけよりもはるかにカーブを感じさせる。

パレットはcolors.cppに、昼、夕暮れ、夜の3セットのRGB565色として存在する。空、明るい草と暗い草、明るい道路と暗い道路、ランブルストリップ、車線標示、霧。ゲームは180,000ワールド単位の移動ごとに次のセットに切り替わる。最高速度では約14秒だ。霧は距離に対して指数的で、セグメントごとにブレンドされる。

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

lerpCol()は2つのRGB565値をチャンネルごとにブレンドする。これで道路と草を霧の色にフェードさせるのに十分で、24ビットに変換する必要は一切ない。

物理演算 — 車らしさを生むもの

グラフィックスはレーサーに注目を集める。物理演算は、誰かが1分以上遊ぶかどうかを決める。調整のすべては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

physics.cppの更新は順に4つのことを行う。傾斜は現在のセグメントと1つ前のセグメントの高さの差から読み取られ、加速度に変換されるので、登りは速度を削り、下りは速度を加える。摩擦は乗法的な減衰として適用される。時間をかけてランプアップし、目標値に跳ね上がることはない加速度が、速度に積分される。そしてカーブが車を横に押す。

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;

横方向の速度がドリフトを生む。カーブに入った車はすぐには滑らない。力が蓄積し、滑りが大きくなり、カーブが終わるとダンピングが車を戻す。ドリフトの角度は横方向と前方向の速度の比から計算され、表示にのみ使われる。

衝突はトラックに沿った距離と横軸での重なりで処理される。遅い交通車両に後ろからぶつかるとその速度の70パーセントまで落ちる。トンネルの壁や木に触れたり、道路から完全に外れたりするとさらに速度が削られ、いずれも閾値を超えると2秒間のクラッシュ状態がトリガーされる。

リポジトリのビルドはデモとして動作する。オートパイロットがこれから来るカーブを読み取ってカウンターステアを切り、スロットルは常に全開だ。2つのボタンはsetup()で宣言されプルアップされ、PCエミュレータは矢印キーをそれらにマッピングするので、手動ステアリングはhandleInput()の数行で済む。

これらの定数はどれも公式から来たものではない。紙の上で遠近法をスケッチし、スプレッドシートで速度曲線を組み、それから車が正しく感じられるまで一度に1つの数値を変えた。そのループが機能したのは、各反復が数秒で済んだからであり、それがエミュレータの話につながる。

RaylibでPC上で開発する

ESP32-S3へのフラッシュは、定数を感覚で調整するには長すぎる。そこで同じソースが、Windows上でRaylibに対してコンパイルされる。ArduinoとTFT_eSPIの呼び出しはemulator/フォルダ内の薄いシムに置き換えられる。

シムが正しく処理しなければならないことの一例を示す。

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は巻き方向を気にしないが、Raylibは気にする。そして道路のコードはどちらも約束しない。それぞれを2回描画するのはGPU上では何のコストもかからず、エミュレータをボードに対してピクセル単位で忠実にした。

これが揃えば、ループは編集、make、実行、が数秒で回った。この記事のスクリーンショットはエミュレータのもので、冒頭のアニメーションはボードのものだ。

ボード上のメモリとフレーム予算

バイトの行き先は次のとおり。

バッファサイズ場所
フレームスプライト、320×240×16ビット153,600 BPSRAM
空とスカイラインのスプライト、640×120×16ビット153,600 BPSRAM
カーテクスチャ、128×128×16ビット32,768 Bフラッシュ
カーメッシュ、428頂点と312三角形約10 KBフラッシュ
トラック、200セグメント約6 KB内部SRAM
投影キャッシュとフレームごとの配列数KB内部SRAM

2つのスプライトはcreateSprite()の前にspr.setAttribute(PSRAM_ENABLE, true)で作成される。この1行が、動作するゲームと割り当てに失敗するゲームの違いだ。モジュールの8 MBのPSRAMはほとんど未使用で、重要なのは2つのフル幅バッファが内部メモリをArduinoコアと奪い合わないことだ。

フレームは完全にスプライト内で構成され、ループごとにspr.pushSprite(0, 0)でパネルに1回プッシュされる。これがティアリングを取り除くものであり、同時にフレームレートの上限でもある。40 MHzのSPIで153,600バイトは、描画を一切する前にそれだけで約31 msかかる。ボード上ではゲームは毎秒約30フレームに落ち着く。これはまさにその計算が予測するとおりだ。もっと欲しければ、TFT_eSPIのUser_Setup.hでSPIクロックを上げるのが最初に見るべき場所だ。

両側に建物がある直線の昼間、前方に黄色い交通車両

AIモデルが力不足だったところ

手書きバージョンと並行して、2026年2月に利用可能なコーディングモデルに、説明から同じゲームを作るよう依頼した。起きたことは、できるだけ公平に書くと次のとおりだ。

これのどれも驚くべきことだとは思わない。このプロジェクトが最も必要とした3つのことは、言語モデルが最も苦手とする3つのことだ。

私はこのプロジェクトでAIを使ったし、それが得意なことにはまさに役立った。モジュールレイアウトの足場作り、OBJとPNGのコンバータの作成、そして設計が固まった後の反復的な描画コードのリファクタリングだ。すべての定数、すべての投影、すべての順序の決定は手で行った。モデルは道具であり、設計者ではなかった。それは2026年2月には真実であり、永遠に真実ではないかもしれない。もし説明だけでこれの遊べるバージョンをモデルに作らせることができたら、ぜひ見てみたい。

自分でビルドする

リポジトリはgithub.com/davidmonterocrespo24/esp32s3-arcade-3dにある。

ボード上で。

  1. 上のハードウェア表のピンでILI9341をESP32-S3に配線し、2つの押しボタンをGPIO 17、GPIO 16とグラウンドの間に接続する。
  2. TFT_eSPIライブラリをインストールし、そのUser_Setup.hで同じピンとSPIクロックを設定する。
  3. Arduino IDEでcar_game.inoを開き、ESP32S3 Dev Moduleを選択し、ボードオプションでPSRAMを有効にして、アップロードする。

PC上で(Windows、MinGWとRaylibがインストール済み)。

cd emulator/
make
./car_game_emu.exe

同じ配線はリポジトリのdiagram.jsonに記述されており、はんだごてなしで回路をレイアウトしたいなら、ESP32-S3 DevKitとILI9341はどちらもVelxioの部品カタログに存在する。私はまだエミュレートされたボード内でゲーム全体を動かすことは試していない。TFT_eSPIのピン設定とPSRAMスプライトが、Lチカのスケッチよりも面白いテストにしていて、それは私のやることリストにある。

もしビルドしたり、トラックジェネレータを変更したり、モデルに書かせることに成功したりしたら、リポジトリでissueを開いてほしい。すべて読んでいる。


共有:
David Montero

執筆者

David Montero

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

GitHub velxio.dev

関連記事


前の記事
Seeedの新しいXIAO IPSディスプレイボードが、発売から数日でブラウザ上で動作
次の記事
カスタムチップが本格進化 — 統合エディタ、ライブセンサースライダー、パーソナルチップライブラリ