返回

在没有 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 赛车的开始画面,然后是包含道路、建筑、车流和速度表的比赛画面

游戏的功能

整个项目大约 2500 行 C++ 代码,基于 Arduino 框架,通过 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 渲染器需要深度缓冲,而微控制器没有足够的空间。这个算术值得算一算,因为它揭示了真正的约束在哪里。

一帧 320×240 的 RGB565 图像是 153,600 字节。同样大小的 16 位深度缓冲又是 153,600 字节。ESP32-S3 有 512 KB 的内部 SRAM,其中很大一部分被 Arduino 核心、显示驱动和堆占用,所以两个全屏缓冲无法舒适地放入内部内存。有了 PSRAM 它们确实放得下。内存方面的论据是站不住脚的。

更有力的论据是逐像素开销。深度缓冲意味着每个三角形的每个像素都要进行一次读取、一次比较和一次条件写入,全部用软件实现,而核心还要每帧填充 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 米,所以一个单位约为 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 网格,通过一个小型 Python 脚本离线转换为 C 头文件,所以运行时不需要解析任何东西:

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 数据,存储在 flash 中,通过 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 像素的三角形。这两个保护措施都源于真实的 bug:一个排序错误或被裁剪的顶点会变成横跨整个画面的条纹,而丢弃三角形比正确裁剪它要便宜得多。

赛车下方是一个由水平线堆叠而成的深色椭圆。它随赛车的横向位置移动,是整个项目中最廉价但对赛车贴地感影响最大的东西。

玩家赛车在日落弯道中漂移经过建筑,速度表显示 262 km/h

天空、视差和一天中的时间

天空不是每帧都绘制的。启动时,initBackground() 在 PSRAM 中构建一个 640×120 的精灵(屏幕宽度的两倍,屏幕上半部分的高度),包含垂直渐变、一个太阳,以及两层带有亮窗的程序化天际线。每一帧主循环按与当前弯道和速度成比例的量滚动它,并绘制两次以实现无缝环绕:

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() 中声明并上拉,PC 模拟器将方向键映射到它们,所以手动转向只需在 handleInput() 中写几行代码。

这些常量没有一个是来自公式。我在纸上勾勒透视,在电子表格中构建速度曲线,然后一次改变一个数字,直到赛车手感对了。这个循环之所以有效,是因为每次迭代只需几秒钟,这就引出了模拟器。

使用 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 关心,而道路代码从不保证绕序。在 GPU 上每个画两次没有成本,并让模拟器与开发板像素级一致。

有了这些,循环就变成了编辑、make、运行,只需几秒钟。本文中的截图来自模拟器;顶部的动画是开发板。

开发板上的内存和帧预算

字节的去向:

缓冲大小位置
帧精灵,320×240×16 位153,600 BPSRAM
天空和天际线精灵,640×120×16 位153,600 BPSRAM
赛车纹理,128×128×16 位32,768 BFlash
赛车网格,428 个顶点和 312 个三角形约 10 KBFlash
赛道,200 个路段约 6 KB内部 SRAM
投影缓存和每帧数组几 KB内部 SRAM

两个精灵在 createSprite() 之前通过 spr.setAttribute(PSRAM_ENABLE, true) 创建。这一行就是游戏能运行和分配失败之间的区别。模块上的 8 MB PSRAM 大部分未使用;重要的是两个全宽缓冲不会与 Arduino 核心争夺内部内存。

帧完全在精灵中合成,每个循环通过 spr.pushSprite(0, 0) 推送到面板一次。这就是消除撕裂的原因,也是帧率的上限:153,600 字节通过 40 MHz SPI 传输本身就需要约 31 毫秒,还没算任何绘制。在开发板上游戏稳定在约 30 帧每秒,这正是那个算术所预测的。如果想要更高,首先应该看的是 TFT_eSPI 的 User_Setup.h 中提高 SPI 时钟。

白天的直道上两侧有建筑,前方有一辆黄色交通车辆

AI 模型在哪里表现不足

在手写版本的同时,我让 2026 年 2 月可用的编程模型根据描述构建同样的游戏。以下是我尽可能公平地描述的结果:

我认为这些都不令人惊讶。这个项目最需要的三样东西,正是语言模型最缺乏的三样:

我确实在这个项目中使用了 AI,它恰好在我擅长的方面很有用:搭建模块布局、编写 OBJ 和 PNG 转换器,以及在设计确定后重构重复的绘制代码。每一个常量、每一个投影和每一个排序决策都是手工做出的。模型是工具,不是建筑师。这在 2026 年 2 月是事实,也许不会永远如此;如果你能让模型仅凭描述就生成这个游戏的可玩版本,我真的很想看看。

自己动手构建

仓库位于 github.com/davidmonterocrespo24/esp32s3-arcade-3d。

在开发板上:

  1. 按照上面硬件表中的引脚将 ILI9341 连接到 ESP32-S3,并将两个按钮连接在 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 精灵使它比闪烁 sketch 更有趣的测试,这在我的待办清单上。

如果你构建了它、修改了赛道生成器,或者成功让模型写出了它,请在仓库中开一个 issue。我会阅读所有的 issue。


分享到:
David Montero

作者

David Montero

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

GitHub velxio.dev

相关文章


上一篇
Seeed 全新 XIAO IPS 显示屏开发板,发布数日后即可在浏览器中运行
下一篇
自定义芯片的成长——一个编辑器、实时传感器滑块、个人芯片库