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

Что делает игра
Всё это — около 2500 строк C++ на фреймворке Arduino, рисующих через библиотеку TFT_eSPI. В комплекте идёт:
- Дорога из сегментов с поворотами, подъёмами и спусками, генерируемая случайно при каждой загрузке.
- Текстурированная 3D-машина игрока, загружаемая из OBJ-меша: 428 вершин, 312 треугольников, текстура 128 на 128.
- Здания по обе стороны дороги, один длинный туннель с потолочными огнями, а также деревья, кусты, камни и фонарные столбы в промежутках.
- Шесть машин трафика с собственной скоростью и полосой.
- Цикл дня, заката и ночи с экспоненциальным туманом к горизонту.
- Физика с разгоном, трением, гравитацией на подъёмах и боковым сносом в поворотах, плюс столкновения с трафиком, стенами и придорожными объектами.
- HUD с круглым спидометром, счётчиком кругов и текущим и лучшим временем круга.
Железо намеренно самое обычное:
| Компонент | Детали |
|---|---|
| SoC | ESP32-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 (вправо), с внутренними подтягивающими резисторами |

Почему здесь нет 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() делает два прохода по
сегментам, в противоположных направлениях.
- От ближних к дальним, проецирование. Первый цикл идёт от
ближайшего сегмента наружу, проецирует каждый край в
rCache[n]и записывает вrClip[n]самую низкую экранную строку, которая всё ещё видна на этом расстоянии. По мере продвижения циклаmaxyтолько движется вверх: сегмент за гребнем холма проецируется ниже гребня и отсекается. Так холмы перекрывают то, что за ними, без какого-либо сравнения глубины. - От дальних к ближним, отрисовка. Второй цикл идёт от дальнего
конца к камере и рисует траву, обочины, дорогу и линии разметки
каждого сегмента как горизонтальные полосы между
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:
- Преобразование. Каждая вершина поворачивается вокруг вертикальной оси на угол, производный от бокового положения машины (машина заметно поворачивает в поворот), затем наклоняется по уклону дороги, усреднённому по следующим шести сегментам и сглаженному во времени, чтобы машина наклонялась на холмах без дрожания.
- Проекция.
x = centerX + rx * fov / z, с фиксированным расстоянием камеры и фокусным расстоянием 130 пикселей. Вершины позади камеры помечаются, и их треугольники пропускаются. - Сортировка. Вычисляется средняя глубина каждого треугольника, и 312 треугольников сортируются от дальнего к ближнему. Сортировки вставками достаточно при таком размере, и она почти бесплатна, когда порядок почти не меняется между кадрами.
- Отсечение и освещение. Треугольники, обращённые в сторону, отбрасываются по знаку 2D векторного произведения. Нормаль грани вычисляется в пространстве объекта и скалярно умножается на фиксированный свет сверху и слегка спереди, давая яркость от 0.35 до 1.0.
- Растеризация. Каждый треугольник рисуется построчным растеризатором с аффинным текстурным отображением.
Растеризатор — это часть, на которую я потратил больше всего времени. Он сортирует три вершины по экранному 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 пикселей за экраном. Обе защиты
существуют из-за реальных багов: одна неправильно отсортированная или обрезанная вершина превращается
в полосу через весь кадр, и гораздо дешевле отбросить
треугольник, чем правильно его обрезать.
Под машиной находится тёмный эллипс, нарисованный как стопка горизонтальных линий. Он смещается вместе с боковым положением машины и является самой дешёвой вещью в проекте с наибольшим влиянием на то, насколько «приземлённо» выглядит машина.

Небо, параллакс и время суток
Небо не рисуется каждый кадр. При загрузке 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/:
car_game_wrapper.cppпросто делает#include "../car_game.ino", так что скетч становится обычной единицей трансляции C++ без правок.Arduino.hиArduino.cppпредоставляютmillis(),delay(),random(),Serial, который печатает в stdout, иdigitalRead(), который возвращает состояние стрелок для двух пинов кнопок.TFT_eSPI.cppреализуетTFT_eSpriteповерх рендер-текстуры Raylib.fillRect,fillTriangle,drawPixelи остальные становятся вызовами отрисовки Raylib;pushSprite()блитит текстуру в окно.
Один пример того, что прослойка должна делать правильно:
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 года, построить ту же игру по описанию. Вот что произошло, настолько честно, насколько могу изложить:
- ChatGPT выдал код, который не компилировался, а после ручного исправления его расчёты перспективы были далеки от истины. Его неизменный совет был добавить Z-буфер.
- Claude выдал лучшую структуру файлов и самый читаемый код, а затем сломал конвейер рендеринга. Он не сохранил порядок от дальнего плана к ближнему для дороги, туннеля и зданий, и внёс тонкие ошибки в математику с плавающей точкой.
- Gemini 3 Pro ближе всех подошёл к математике проекции, а затем вообще не имел мнения об остальном. Цвета выглядели неправильно, физика ощущалась плавающей, а когда в его собственном выводе были артефакты рендеринга, он не мог их найти.
Я не думаю, что что-то из этого удивительно. Три вещи, которые проекту нужны больше всего, — это три вещи, которых у языковой модели меньше всего:
- Порядок в пространстве. Знать, что перед чем, на холме, внутри туннеля, — это пространственный факт, а не текстовый.
- Числовая аккуратность. Файл OBJ имеет ось Y вверх, а экран — Y вниз, ось V текстуры идёт снизу вверх, а смещение поворота накапливается в плавающей точке по сорока сегментам каждый кадр. Каждое из этого — один знак или одно округление от дороги, которая дрейфует, или автомобиля, нарисованного наизнанку.
- Вкус. Является ли 0.18 или 0.25 правильной центробежной константой, нельзя вывести. Это нужно прочувствовать за рулём.
Я действительно использовал ИИ в этом проекте, и он был полезен именно для того, в чём хорош: создание каркаса модулей, написание конвертеров OBJ и PNG, и рефакторинг повторяющегося кода отрисовки, когда дизайн был устоявшимся. Каждая константа, каждая проекция и каждое решение о порядке были приняты вручную. Модель была инструментом, а не архитектором. Это было верно в феврале 2026 года, и это может не быть верным навсегда; если вы заставите модель создать играбельную версию этого только по описанию, я бы искренне хотел это увидеть.
Соберите сами
Репозиторий — github.com/davidmonterocrespo24/esp32s3-arcade-3d.
На плате:
- Подключите ILI9341 к ESP32-S3 по пинам из таблицы аппаратного обеспечения выше, а также две кнопки между GPIO 17, GPIO 16 и землёй.
- Установите библиотеку TFT_eSPI и задайте те же пины и тактовую частоту SPI в
её
User_Setup.h. - Откройте
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 в репозитории. Я читаю все.