Назад

Создание 3D-гонки в стиле OutRun для ESP32-S3 без Z-буфера

В феврале 2026 года я поспорил на Reddit. Кто-то утверждал, что новейшие модели для программирования пишут код лучше любого человека-разработчика, и в доказательство ссылался на свежие релизы OpenAI и Anthropic. Я не согласился. Модели очень хороши в лендингах, CRUD-бэкендах и демках, у которых на GitHub тысяча копий. У меня были сомнения насчёт того, что происходит, когда задача требует пространственного мышления, аккуратной арифметики с плавающей точкой и мнения о том, что приятно в игре.

Поэтому я выбрал проект, который почти не встречается в интернете, и сделал его сам: псевдо-3D гоночную игру в стиле OutRun, работающую на ESP32-S3 с дисплеем ILI9341. Попутно я попросил модели сделать то же самое. Эта статья объясняет, как устроена игра, подробнее, чем более короткая заметка, опубликованная мной на Medium, и заканчивается тем, в чём ошиблись модели. Полный исходный код — на github.com/davidmonterocrespo24/esp32s3-arcade-3d.

Игра, работающая на ESP32-S3 и дисплее ILI9341: стартовый экран с вращающейся 3D-машиной, затем гонка с дорогой, зданиями, трафиком и спидометром

Что делает игра

Всё это — около 2500 строк C++ на фреймворке Arduino, рисующих через библиотеку TFT_eSPI. В комплекте идёт:

Железо намеренно самое обычное:

КомпонентДетали
SoCESP32-S3, 240 МГц двухъядерный, с 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-рендереру нужен буфер глубины, а у микроконтроллера нет для него места. Арифметику стоит провести, потому что она показывает, в чём настоящее ограничение.

Кадр 320 на 240 в RGB565 — это 153 600 байт. 16-битный буфер глубины того же размера — ещё 153 600 байт. У ESP32-S3 512 КБ внутренней SRAM, значительную часть которой занимают ядро Arduino, драйвер дисплея и куча, так что два полноэкранных буфера не помещаются во внутреннюю память с комфортом. С PSRAM они помещаются. Аргумент о памяти слабый.

Более сильный аргумент — стоимость на пиксель. Буфер глубины означает чтение, сравнение и условную запись для каждого пикселя каждого треугольника, программно, на ядре, которое ещё должно заполнить 76 800 пикселей за кадр и вытолкнуть их по SPI. При 240 МГц этот бюджет тонок.

У аркадных гонок 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 м, так что одна единица — это около 5,25 мм. Все остальные константы в config.h выражены в этих единицах.

Основная техника следует за статьями Jake Gordon о гоночной игре на JavaScript, которые являются самым ясным известным мне объяснением псевдо-3D дорог. Камера находится на высоте CAM_HEIGHT единиц над дорогой. Поле зрения задаёт глубину камеры:

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

Проекция края сегмента — это тогда одно деление и два умножения. 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() делает два прохода по сегментам, в противоположных направлениях.

  1. От ближних к дальним, проецирование. Первый цикл идёт от ближайшего сегмента наружу, проецирует каждый край в rCache[n] и записывает в rClip[n] самую низкую экранную строку, которая всё ещё видна на этом расстоянии. По мере продвижения цикла maxy только движется вверх: сегмент за гребнем холма проецируется ниже гребня и отсекается. Так холмы перекрывают то, что за ними, без какого-либо сравнения глубины.
  2. От дальних к ближним, отрисовка. Второй цикл идёт от дальнего конца к камере и рисует траву, обочины, дорогу и линии разметки каждого сегмента как горизонтальные полосы между rCache[n] и rCache[n-1], отсечённые по rClip. Вблизи камеры сегмент может быть высотой во много строк, поэтому он разбивается на до трёх полос с шириной, интерполированной между двумя краями, что не даёт поворотам выглядеть как составленные трапеции.

Здания и туннель используют один и тот же цикл

В ранней версии дорога рисовалась в одном цикле, а здания — в другом. На ровной земле это выглядело правильно, но на холмах всё разваливалось: здание, которое должно было скрываться за гребнем, рисовалось поверх него, потому что второй цикл понятия не имел, что первый уже нарисовал.

Решение состояло в том, чтобы рисовать всё, что принадлежит сегменту, внутри одного и того же цикла от дальнего плана к ближнему. Для каждого сегмента по порядку: стены и потолок туннеля, если сегмент находится внутри туннеля, здания, если он снаружи, затем поверхность дороги. Здание — это пара квадов (передняя грань и боковая грань), построенных из спроецированных краёв дороги этого сегмента и предыдущего:

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

Туннель — та же идея с квадами, развёрнутыми внутрь: квад потолка на фиксированной высоте над дорогой, два квада стен, жёлтый свет каждые четыре сегмента и серый портал, нарисованный на первом сегменте. Поскольку всё это выводится в порядке художника, вход в туннель на середине холма выглядит правильно без особых случаев.

Придорожные спрайты и машины трафика идут в третьем, коротком цикле после того, как дорога завершена. Они рисуются как плоские фигуры, масштабированные масштабом проекции сегмента и обрезанные по rClip, так что дерево за холмом обрезается на гребне точно так же, как дорога.

Внутри туннеля ночью: потолочные огни, синие стены, машина игрока и спидометр

Машина игрока — настоящая 3D

Я не хотел спрайт для игрока. Машина — это OBJ-меш, преобразованный офлайн в C-заголовок небольшим Python-скриптом, так что во время выполнения ничего не парсится:

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 КБ RGB565 во флеш-памяти, читаемые через pgm_read_word(), так что она никогда не касается RAM.

Каждый кадр меш проходит через небольшой фиксированный конвейер в render_player.cpp:

  1. Преобразование. Каждая вершина поворачивается вокруг вертикальной оси на угол, производный от бокового положения машины (машина заметно поворачивает в поворот), затем наклоняется по уклону дороги, усреднённому по следующим шести сегментам и сглаженному во времени, чтобы машина наклонялась на холмах без дрожания.
  2. Проекция. x = centerX + rx * fov / z, с фиксированным расстоянием камеры и фокусным расстоянием 130 пикселей. Вершины позади камеры помечаются, и их треугольники пропускаются.
  3. Сортировка. Вычисляется средняя глубина каждого треугольника, и 312 треугольников сортируются от дальнего к ближнему. Сортировки вставками достаточно при таком размере, и она почти бесплатна, когда порядок почти не меняется между кадрами.
  4. Отсечение и освещение. Треугольники, обращённые в сторону, отбрасываются по знаку 2D векторного произведения. Нормаль грани вычисляется в пространстве объекта и скалярно умножается на фиксированный свет сверху и слегка спереди, давая яркость от 0.35 до 1.0.
  5. Растеризация. Каждый треугольник рисуется построчным растеризатором с аффинным текстурным отображением.

Растеризатор — это часть, на которую я потратил больше всего времени. Он сортирует три вершины по экранному Y, проходит по строкам между верхом и низом и для каждой строки интерполирует левый и правый X и текстурные координаты вдоль двух активных рёбер. Затем он шагает по строке, выбирает текстуру, применяет освещение и записывает один пиксель:

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. На машине, занимающей около пятой части экрана, всегда на одном и том же расстоянии, ошибка меньше пикселя, и деление на пиксель, которое потребовалось бы для корректного отображения, не стоит того. Рендерер также отказывается от любой строки развёртки шириной более 160 пикселей и от любого треугольника с вершиной более чем на 20 пикселей за экраном. Обе защиты существуют из-за реальных багов: одна неправильно отсортированная или обрезанная вершина превращается в полосу через весь кадр, и гораздо дешевле отбросить треугольник, чем правильно его обрезать.

Под машиной находится тёмный эллипс, нарисованный как стопка горизонтальных линий. Он смещается вместе с боковым положением машины и является самой дешёвой вещью в проекте с наибольшим влиянием на то, насколько «приземлённо» выглядит машина.

Машина игрока дрифтует через закатный поворот мимо зданий, спидометр показывает 262 км/ч

Небо, параллакс и время суток

Небо не рисуется каждый кадр. При загрузке initBackground() строит спрайт 640 на 120 в PSRAM (вдвое шире экрана, высотой в верхнюю половину экрана) с вертикальным градиентом, солнцем и двумя слоями процедурного скайлайна с освещёнными окнами. Каждый кадр главный цикл прокручивает его на величину, пропорциональную текущему повороту и скорости, и блитит его дважды, чтобы зацикливание было бесшовным:

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

Это эффект, который использует Horizon Chase: горизонт скользит в сторону, когда вы поворачиваете, что передаёт поворот гораздо лучше, чем одна только геометрия дороги.

Палитра живёт в colors.cpp как три набора цветов RGB565 для дня, заката и ночи: небо, светлая и тёмная трава, светлая и тёмная дорога, отбойники, дорожная разметка и туман. Игра переключается на следующий набор каждые 180 000 мировых единиц пути, что на максимальной скорости составляет около четырнадцати секунд. Туман экспоненциальный по расстоянию и смешивается по сегментам:

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

lerpCol() смешивает два значения RGB565 канал за каналом, чего достаточно, чтобы растворить дорогу и траву в цвете тумана, никогда не преобразуя в 24-битный формат.

Физика: что заставляет её ощущаться как автомобиль

Графика привлекает внимание к гонке. Физика решает, будет ли кто-то играть в неё дольше минуты. Вся настройка живёт в 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 делает четыре вещи по порядку. Наклон считывается из разницы высот между текущим сегментом и предыдущим и превращается в ускорение, так что подъёмы отнимают скорость, а спуски добавляют её. Трение применяется как мультипликативное затухание. Ускорение, которое нарастает со временем, а не скачет сразу к целевому значению, интегрируется в скорость. Затем поворот толкает автомобиль в сторону:

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;

Именно боковая скорость даёт занос. Автомобиль, входящий в поворот, не скользит сразу; сила накапливается, занос растёт, а когда поворот заканчивается, демпфирование возвращает автомобиль обратно. Угол заноса затем вычисляется из отношения боковой скорости к продольной и используется только для отображения.

Столкновения обрабатываются по расстоянию вдоль трассы плюс перекрытие по боковой оси. Удар сзади в более медленный автомобиль трафика сбрасывает вас до семидесяти процентов его скорости; касание стены туннеля, дерева или выезд с дороги полностью срезает больше скорости, и любое из них выше порога запускает двухсекундное состояние аварии.

Сборка в репозитории работает как демо: автопилот читает предстоящий поворот и контррулит в него, а газ всегда нажат. Две кнопки объявлены и подтянуты в setup(), а ПК-эмулятор отображает стрелки на них, так что ручное управление — это несколько строк в handleInput().

Ни одна из этих констант не выведена из формулы. Я набросал перспективу на бумаге, построил кривые скорости в электронной таблице, а затем менял по одному числу за раз, пока автомобиль не стал ощущаться правильно. Этот цикл работал только потому, что каждая итерация занимала секунды, что подводит меня к эмулятору.

Разработка на ПК с Raylib

Прошивка 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 заботится, а код дороги никогда его не гарантирует. Рисование каждого дважды ничего не стоит на GPU и сделало эмулятор попиксельно точным по отношению к плате.

С этим на месте цикл стал: правка, make, запуск, за несколько секунд. Скриншоты в этой статье — из эмулятора; анимация вверху — это плата.

Память и бюджет кадра на плате

Куда уходят байты:

БуферРазмерГде
Спрайт кадра, 320 на 240 на 16 бит153 600 БPSRAM
Спрайт неба и силуэта, 640 на 120 на 16 бит153 600 БPSRAM
Текстура автомобиля, 128 на 128 на 16 бит32 768 БFlash
Меш автомобиля, 428 вершин и 312 треугольниковоколо 10 КБFlash
Трасса, 200 сегментовоколо 6 КБВнутренняя SRAM
Кэши проекции и массивы на кадрнесколько КБВнутренняя SRAM

Два спрайта создаются с spr.setAttribute(PSRAM_ENABLE, true) перед createSprite(). Эта единственная строка — разница между игрой, которая работает, и той, которая не может выделить память. 8 МБ PSRAM на модуле в основном не используются; важно то, что два буфера на всю ширину не конкурируют с ядром Arduino за внутреннюю память.

Кадр полностью компонуется в спрайте и выталкивается на панель один раз за цикл с помощью spr.pushSprite(0, 0). Именно это убирает разрывы, и это же потолок частоты кадров: 153 600 байт по SPI на 40 МГц занимают около 31 мс сами по себе, до всякой отрисовки. На плате игра держится около 30 кадров в секунду, что как раз предсказывает эта арифметика. Повышение тактовой частоты SPI в User_Setup.h TFT_eSPI — первое место, куда стоит посмотреть, если хотите больше.

День на прямом участке со зданиями по обе стороны и жёлтым автомобилем трафика впереди

В чём ошиблись модели ИИ

Параллельно с написанной вручную версией я попросил модели кодинга, доступные в феврале 2026 года, построить ту же игру по описанию. Вот что произошло, настолько честно, насколько могу изложить:

Я не думаю, что что-то из этого удивительно. Три вещи, которые проекту нужны больше всего, — это три вещи, которых у языковой модели меньше всего:

Я действительно использовал ИИ в этом проекте, и он был полезен именно для того, в чём хорош: создание каркаса модулей, написание конвертеров OBJ и PNG, и рефакторинг повторяющегося кода отрисовки, когда дизайн был устоявшимся. Каждая константа, каждая проекция и каждое решение о порядке были приняты вручную. Модель была инструментом, а не архитектором. Это было верно в феврале 2026 года, и это может не быть верным навсегда; если вы заставите модель создать играбельную версию этого только по описанию, я бы искренне хотел это увидеть.

Соберите сами

Репозиторий — github.com/davidmonterocrespo24/esp32s3-arcade-3d.

На плате:

  1. Подключите ILI9341 к ESP32-S3 по пинам из таблицы аппаратного обеспечения выше, а также две кнопки между GPIO 17, GPIO 16 и землёй.
  2. Установите библиотеку TFT_eSPI и задайте те же пины и тактовую частоту SPI в её User_Setup.h.
  3. Откройте car_game.ino в Arduino IDE, выберите ESP32S3 Dev Module, включите PSRAM в настройках платы и загрузите прошивку.

На ПК (Windows, установлены MinGW и Raylib):

cd emulator/
make
./car_game_emu.exe

Та же схема подключения описана в diagram.json репозитория, а как ESP32-S3 DevKit, так и ILI9341 присутствуют в каталоге компонентов Velxio, если вы хотите собрать схему без паяльника. Я пока не пробовал запускать полную игру внутри эмулируемой платы; настройка пинов TFT_eSPI и спрайт в PSRAM делают это более интересным тестом, чем скетч мигания светодиодом, и это в моём списке.

Если вы её соберёте, измените генератор трассы или сумеете заставить модель написать её, откройте issue в репозитории. Я читаю все.


Поделиться:
David Montero

Автор

David Montero

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

GitHub velxio.dev

Похожие статьи


Предыдущая статья
Новые платы XIAO с IPS-дисплеями от Seeed — работают в браузере через несколько дней после анонса
Следующая статья
Кастомные чипы взрослеют — один редактор, живые слайдеры датчиков, личная библиотека чипов