B博客

ESP32-S3 上的 60 fps,纯软件实现

一个每个像素都要调用一次函数的 memcpy,一帧要花 31 ms 才能发出去,还有一个闲着的第二核心。一个 320×240 的游戏如何从每秒 16 帧提升到 60 帧。

发布于
作者
DevKirbyrawr
阅读
约 3 分钟
引擎以 RGB565 渲染的三帧 320×240 画面。

Deki 的一切都用软件绘制:没有 GPU,只有 CPU 填充帧缓冲,再由总线把它送到屏幕上。在一块带 PSRAM、接 320×240 SPI 显示屏的 ESP32-S3 上,演示最初只跑到每秒 16 帧。屏幕上的内容根本不该这么慢:一个背景、几个精灵、一些文字和少量粒子。现在每个场景都能跑到 60 到 63 帧。下面是当初挡路的那些东西。

在设备上测量

桌面上的性能分析对微控制器说明不了多少,所以第一个改动是加了一个直接询问开发板的方法。固件现在会通过它的 USB 串口响应几种请求:每秒帧数,以及平均每帧的更新、渲染和发送时间;按组件类型统计的渲染时间;还有“打开这个场景”,这样脚本就可以把每个场景挨个测一遍。(编辑器也用这些请求,见来自开发板的截图和统计。)下面的每个数字都来自这里。

编译器在为调试器构建

ESP-IDF 默认用 -Og 构建,这是为调试优化的。逐像素的 C++ 恰恰是这样构建后最慢的代码,Deki 的设备构建现在改用 -O2。

每个像素一次函数调用

像素是用 memcpy(&v, p, 2) 读取的,这是加载两个可能未对齐字节的可移植写法。在 x86 上这是一条指令。而 S3 的 Xtensa 核心不支持未对齐加载,GCC 把它编译成了对 memcpy 的调用,每个精灵的每个像素都调用一次。改为分别读取这两个字节再移位拼起来之后,blit 速度快了六倍。

不做算术的着色

着色的精灵(比如一个带颜色的 UI 面板)会走通用管线:逐像素解包、每个通道相乘、相除、混合、打包。但 RGB565 图像只有 32 种红、64 种绿和 32 种蓝,所以现在着色在每次绘制时只计算一次,做成查找表,一个不透明像素只需要三次查表。有一个测试会检查每个像素的结果都和以前完全一样。

更好的是干脆不着色。九宫格面板本来就会被拉伸成一张缓存图像,所以现在着色也一并烘焙进这张图像里,只有着色改变时才重新计算。主要由面板构成的界面从 55 fps 提升到了约 62 fps。

发送一帧要 31 ms

一帧 320×240 的画面是 150 KB。在显示屏最初使用的 40 MHz SPI 时钟下,发送它要 31 ms,在 60 fps 下几乎相当于整整两帧。这块屏幕支持 80 MHz,时间因此减半。

但这样绘制和发送仍然是一个接一个进行的,而 S3 的第二个核心闲着。现在画好的帧会放进 PSRAM 中两个缓冲区里的一个,在绘制下一帧的同时,由另一个核心把它发送出去:

时间线:单核时每一帧先绘制再发送;双核时,核心 0 绘制的同时,核心 1 发送上一帧

开发板只需在显示设置里开启一个选项即可启用。

小一些的收获,以及一个没成功的

  • 缓存行。 帧缓冲位于 PSRAM 中,S3 的数据缓存在 64 字节缓存行下处理得更好。更大的缓存还能更快,但它要占用内部 RAM,一个精灵很多的场景因此内存耗尽并崩溃了。
  • 粒子。 每个粒子在写入发射器图像时都会走一遍通用管线。为这种情况专门写一个循环后,两个喷泉的耗时从每帧 3.6 ms 降到了 2.4 ms。
  • 脏矩形毫无作用:每个场景都有一个会变化的全屏背景,所以整个屏幕始终是脏的。保留它们是为了那些大部分内容静止的场景。

最终结果

每个演示场景的每秒帧数:首次运行为 16;现在每个场景都在 61.8 到 62.8 之间,高于 60 fps 线

灰色条是在着色烘焙和粒子循环之前、其他修复都已加入时的最后一次测量。测试用的开发板是 LilyGO T-Deck Plus,但这里没有任何内容是它专有的:任何带 PSRAM、通过 LovyanGFX 驱动 SPI 屏幕的 S3 都会走同样的路径。

← 全部文章