如何在PaperS3上创建一个GameBoy模拟器
引言
最近我入手了一款M5PaperS3,它是一款形似便携式电子阅读器的ESP32-S3开发套件,配备一块4.7英寸960×540分辨率的Eink触摸屏。与其他小型Eink开发套件相比,它的特别之处在于屏幕采用了原始行/列驱动接口(在讨论Eink屏幕时,这种接口也常被称为并行接口,但与LCD屏幕上的并行接口并不相同)。这让我能够通过绕过常规波形方法来驱动该屏幕,从而实现高达60fps的高刷新率。关于这一点,稍后再详谈。这让我开始思考:除了编写电子阅读器应用程序之外,还能用它做些什么有趣的事情呢?几年前,我曾重新实现过GameBoy的FPGA版本,因此可以说我对GameBoy架构至少有一定的了解。那么,将两者结合起来会怎样呢?
我在网上搜索,却从未有人尝试过制作这样的东西。因此,我非常有动力在PaperS3上创建第一个GameBoy模拟器,并让它真正能够运行起来!
Eink 驱动器
我首先着手开发的是高刷新率的Eink驱动器,因为这个组件决定了这个项目能否成功。
Eink 通常被认为响应较慢:刷新率低,响应速度慢。这通常是正确的,因为 Eink 面板内部的彩色粒子移动速度确实很慢,通常需要约 100 毫秒才能改变颜色,因此刷新频率约为 10Hz。这种缓慢的响应和较长的延迟是其副作用所致:用户可能需要等待长达 100 毫秒,屏幕才显示图像。如果操作系统使用了三重缓冲技术,则会进一步增加 100 毫秒的延迟。
许多现有的快速刷新策略本质上都是在缩短驱动时间。例如,如果仅将屏幕驱动50毫秒,就能将帧率提升至20fps,但代价是对比度降低(因为像素未能完全驱动)。然而,这种方法的极限是有限的:即使使用最新的Carta 1300屏幕并采用IGZO背板,在±24V驱动电压下,也只能将刷新率推高到30Hz,之后画面会变得过于模糊而无法使用。M5PaperS3则采用较老一代的屏幕(Carta 1000,驱动电压为±15V),其最高刷新率约为15fps。
但仍有办法让它正常工作。你看,Eink 的刷新率很慢,只有 100 毫秒。但这并不意味着你必须将帧率限制在 10Hz 才能获得 100 毫秒的驱动时间。它也可以是每 60Hz 刷新 6 帧。实际上,出于各种原因,屏幕驱动器内部的运行方式就已经如此。但控制器会每 6 帧才接收一次新图像,就像全局锁定一样,从而表现出 10Hz 的行为。但如果去掉这种锁定,让控制器每帧都接收一次新图像,我们就能解锁 60Hz 的刷新率了。
这完全有可能。但需要付出的代价是,现在你需要跟踪每个像素的时间,以确保它们总共仍能连续驱动6帧。这需要为每个像素添加一个新的状态缓冲区,用于读取和写入。而全局锁方法只需一个计数器,每帧更新一次。因此,这种方法显然并不免费。根据平台不同,这种开销可能非常高(例如在低端微控制器上,或高端微控制器但SRAM容量有限、PSRAM带宽受限的情况下)。
但我们在Eink屏幕上能获得流畅的60帧每秒,如同黄油般顺滑:
那是我在2022年做的一个FPGA演示。之后,我将这个演示改造成了一款基于FPGA的Eink显示器,名为Modos Flow。现在我们只需看看是否能在ESP32上实现同样的功能。
幸运的是,这个项目并不需要以全屏像素速率进行处理。GameBoy 只有 160x144 像素的分辨率。我需要用 3 个像素来表示 4 种灰度,通过将抖动图像复制 3 次,就能实现 3 倍像素的完美缩放,并保留抖动效果。这意味着我只需在 60Hz 下处理 160×3×144 像素,这比 960×540 在 60Hz 下的目标低得多。这也意味着我的所有缓冲区都可以完全存放在 SRAM 中,无需将数据写入速度较慢的 PSRAM。我做了一些性能测试,当前在 480×144 分辨率下运行的代码仅使用了 CPU 时间的约 50%。虽然这并不算特别优化,但也能大致反映出我们距离全分辨率还有多远。
我每像素存储4位状态,用于统计像素的状态。该状态与输入缓冲区进行比较,以决定向屏幕发送什么内容。通过LCDIF + DMA技术将像素实际输出到屏幕,使核心能够计算下一批要显示的像素。整个过程在ESP32的第二个核心上运行,每次迭代设置为约1/60秒,从而实现60Hz的刷新率。同时实现了双缓冲机制,使得第一个核心上的模拟器也具备60Hz的时间基准,使其能够支持垂直同步的图像渲染。
GameBoy 模拟器
虽然我过去曾实现过一个GameBoy模拟器,但那也是基于FPGA的。而微控制器上的GB模拟器已经被多次实现了,因此我决定使用现有的模拟器,而不是自己重新发明轮子。
Peanut GB 看起来是最受欢迎的模拟器选项,在微控制器上性能表现良好。
Walnut CGB 是 Peanut GB 的一个分支版本。它修改了模拟器,使其执行 16 位指令加载而非 8 位加载,从而可能节省内存访问时间,主要通过减少内存访问次数来优化昂贵的模拟内存映射路径。
CrankBoy 是 Playdate 项目中 Peanut GB 的一个分支,提供了专为 Playdate 设计的完整优化版 GB 模拟器。该版本在移植过程中进行了大量优化,特别是在内存映射路径方面,使得性能大幅提升,同时避免了 Walnut CGB 中较为特殊的双读取架构带来的问题。
我在ESP32-S3上试用了它们,并在多个游戏标题中测试了模拟速度。以下是测试结果:
性能以GameBoy为基准进行衡量。1表示其运行速度与GameBoy大致相同。
因此,我使用了性能最强的CrankBoy作为模拟核心。通过动态帧跳过(即当模拟器出现延迟时,仅进行模拟运算而不输出到屏幕,从而节省几毫秒以追上进度),它能在许多游戏中达到100%的速度,并以30-60帧每秒的速率进行渲染。实际帧率在很大程度上取决于运行时的具体情况。
目前的性能下,暂时启用GBC支持(可让CPU以双倍时钟频率运行)显得不太现实。我们需要更强大的技术来让GBC以合理速度运行,或使GB游戏在60帧/秒时表现更加稳定。
声音
这三个模拟器都使用 minigb-apu 作为声音模拟引擎。GB 内部集成了一个 4 通道的 PSG(可编程声音发生器),包括 2 个方波通道、1 个采样通道和 1 个噪声通道,这四个通道随后被混合以生成声音输出。在真正的 GB 中,部分混音过程发生在模拟域,但通常对于模拟器来说,最简单的方法是将所有内容直接以 32.768kHz 的采样率转换为 PCM 采样数据播放。现代所有声卡都能播放 PCM 采样数据。
然而,PaperS3的情况并非如此。PaperS3仅配备了一个蜂鸣器,当PWM驱动时只能发出单一频率的音调。一种常用的方法是利用PWM DAC输出PCM采样数据。基本做法就是直接将PCM样本发送到PWM模块中作为占空比(并将波形偏移128位以实现直流偏置)。但ESP32-S3的PWM模块无法通过DMA接收输入,必须通过轮询或在采样率时生成中断来更新占空比寄存器。这两种方式都需要我预留一些CPU周期专门用于声音输出。(注意,默认情况下,ESP32的中断处理速度较慢,这是由于上下文切换和中断调度所致。虽然可以设置32kHz中断,但成本会很高。)因此,我选择了I2S外设的PDM输出功能。该功能原本设计用于连接支持PDM的DAC/放大器,但你也可以简单地加一个RC滤波器,使其自身就成为DAC。我先实现了这个方案,立刻就能正常工作。但问题在于……声音实在太小了。如果处于非常安静的环境中,听起来还算能察觉;否则,几乎就像被静音了一样。我原以为可能是由于调制频率太高(4MHz),导致BJT进入线性区域,所以我也尝试了仅使用32kHz的PWM,但效果并无明显改善,因此我决定继续使用PDM。
但我感觉这还不够。虽然确实有声音,但如果在很多情况下听不到,那也就没什么用处了。要发出足够响亮的蜂鸣声,几乎必须使用单一频率的声音,而这正是它最初设计的目的。那么,为什么不干脆直接使用单个音调呢?要知道,在GameBoy的PSG芯片中,前两个通道是纯正的方波。采样通道也可以用方波来近似(假设游戏用它输出的是正弦波、三角波等)。如果我们把噪声也换成方波(说实话这个想法有点牵强),那就只有四个方波通道了。尽管蜂鸣器只能发出一个音调,但我们可以快速切换多个声音通道,从而产生一种伪多声部效果。这种技术在那些只配备单个蜂鸣器的平台上非常常见,比如没有声卡的IBM兼容电脑。至于效果,我觉得结果出人意料地不错,同时也有不足之处。优点是声音响亮清晰,游戏音乐依然很容易辨认,整体听起来也很有趣。缺点嘛,GameBoy本来就不该发出这样的声音。
为了确保这些操作不会占用有限的CPU资源进行模拟,我将整个PSG模拟器部署在第二核心上。根据对部分游戏的性能分析,PSG模拟器通常每帧最多运行2毫秒,具体取决于声音的复杂程度。
输入
输入方式相当简单。M5PaperS3配备触摸屏,因此在屏幕上直接显示一个触摸式摇杆是顺理成章的。
对于严肃的玩家(???),我已添加了实验性的蓝牙LE手柄支持。虽然它并未完全实现HID协议,仅对报告格式进行猜测,因此兼容性较差。我认为作为演示版本是可以接受的,但肯定还有改进空间。
JIT 2 假期
CrankBoy 的性能已经相当不错,但绝不能说是稳定可靠的 60fps。在游戏过程中,由于 CPU 模拟速度过慢,帧率经常低于 60fps。虽然听起来很奇怪——一个 240MHz 的 Xtensa LX7 无法以全速模拟 4MHz 的 GameBoy CPU,但这正是我们目前的现状。为了进一步提升性能,必须进行一些重大的优化工作。
一种选择是使用即时(JIT)动态重编译器。JIT重编译器会将GameBoy CPU的指令重新编译为Xtensa LX7的指令集,从而使ESP32能够原生执行这些指令,而无需经过解释器。
开发一个真正稳定且高效的即时编译器是一项庞大的工程。
虽然游戏兼容性尚未经过充分测试,但已能成功启动的部分游戏显示出不错的运行速度提升。这在一定程度上证明,结合60Hz屏幕驱动程序,应该能够开发出一种固件,使GameBoy游戏的运行速度接近100%。
保存数据
Game Boy 没有内置的非易失性存储,保存状态存储在卡带中。因此,要让保存的数据在重启后仍然保留,只需将其保存到 SD 卡上即可。简单对吧?其实不然。
将数据保存到SD卡上是容易的部分,问题在于什么时候保存。有些游戏会提供明确的存档菜单,但并非所有游戏都如此。游戏卡带上的硬件通常只是一个由电池供电的SRAM,甚至可能根本没有电池支持,如果游戏只是需要额外的RAM空间时就更常见了。模拟器或许可以根据卡带RAM写入模式的启发式判断,来决定何时将数据刷新到SD卡(例如最简单的形式:如果过去两秒内没有发生更新,就立即刷新)。但这听起来并不可靠:如果我在它完成刷新前关闭了它怎么办?如果游戏每秒持续向卡带RAM写入数据,导致从未触发刷新,又该怎么办?作为玩家,丢失存档数据是我最不想看到的情况之一。当然,我并不是说自动保存不可能实现。我认为这是可行的,而且如果做得恰当,效果可能会非常好。不过我还没有尝试过。
另一种选择是设置一个明确的事件来触发保存,最常见的就是关闭模拟器。但在M5PaperS3上这并不可行,因为S3的电源按钮直接切断电源,而不是尝试优雅地关机。S3甚至不知道自己即将被关闭,更不用说将保存状态刷新到SD卡了。因此我不得不在设备上添加一个保存按钮,明确要求用户按下该按钮以关闭机器。虽然看起来不太美观,但这样就解决了问题。
为了让按钮功能更实用,我添加了另一个功能:快速保存和快速加载。该功能可以存储并加载整个模拟GameBoy的游戏状态,从而快速创建存档点并返回。保存和快速保存使用同一个按钮,按下即可完成两者操作。因此,下次你拿起PaperS3时,只需使用快速加载就能立即回到游戏。
结论
总之,我认为最终成果是一款相当不错且可用的GameBoy模拟器,适用于M5PaperS3,同时也将Eink屏幕发挥到了极限。遗憾的是,PaperS3已经到达了生命周期末期,希望我们能很快得到一个真正合适的替代品!
本文编译自hackster.io





