【GameBoy模拟器 番外篇】将模拟器从本地端移植到web端

先把俄罗斯方块弄到浏览器上玩一下再说

目标

目前macOS本地端有一个能运行俄罗斯方块的GameBoy模拟器,希望移植到浏览器端来玩

成果

俄罗斯方块试玩链接

操作方法:

  • 回车键(Enter/return)开始或暂停游戏
  • 方向键(←↓→)控制方块位置
  • Z键和X键转动方块

按键映射关系:

GameBoy按钮 键盘按键
UP
DOWN
LEFT
RIGHT
A X
B Z
START Enter/return
SELECT Tab

方案

我在macOS下实现了一个基于C语言的gameboy模拟器,它基于SDL2来实现游戏的UI和画面。其CPU已经通过了Blargg‘s test,并能够在本地运行经典的俄罗斯方块游戏。现在我希望将这个模拟器移植到web端,这样就可以在浏览器中玩了。

为了实现这个想法,我的直觉是重新写一套完整的前后端服务。但是考虑具体的前后端时,发现完全没有头绪。在后端方面,有哪些数据需要进行前后端交互,如何获取,如何构造传输形式……这些问题令人头大。另一方面,前端也很复杂,对于我这个前端菜鸟,没有能力靠自己把读来的数据重新绘制成游戏。我问了很久的ChatGPT,对于全栈服务这套方案,它也只能给出一些大致的步骤和思路。

因此,我不断尝试询问,并把询问的关键词从“实现一套前后端系统”换成了“移植”“输出到浏览器”这一类提问,最终我找到了突破口:webAssembly和emscripten。

webAssembly(wasm)很通俗易懂,就是web端运行的assembly(汇编语言)。熟悉虚拟机和解释型语言的人一看便能心领神会,它的思想就是把诸如C, Java, Go, Rust这样的语言编译为一种浏览器的“汇编语言”。

选择编译器

emscripten就是这样一种webAssembly的编译器,它能够把C/C++编译为webAssembly,并且支持SDL2库,完美符合我的需求

嵌入游戏Rom

由于wasm的特性,所有运行时的数据在编译完成时已经确定,不能像本地模拟器那样在运行时去访问磁盘文件中的游戏ROM。因此我使用了xxd工具,将俄罗斯方块的ROM文件转换为C语言的数组形式,然后在C代码中引用这个数组,这样相当于把俄罗斯方块直接嵌入了这个模拟器。

Tetris.h

1
2
3
extern unsigned char roms_Tetris_gb[];

extern unsigned int roms_Tetris_gb_len;

折腾Cmake

添加emscripten编译时所需的链接项,使用修改cmake源的方式来切换编译方式(clang和emcc)。在无止境的修复编译bug的过程中,我发现原始的项目过于复杂,导致emscripten编译起来非常麻烦。

不断做减法

原项目的以下特点在本地运行时没问题,但对于emscripten而言,要么编译难产,要么编译通过但运行效果差,我需要将他们逐一地做减法

去除冗余

原项目不知为何引入了SDL_TTF依赖,不仅没有用而且会导致emscripten编译失败(和-s USE_PTHREAD选项同时使用会报错,浪费了我许多时间),删掉。

多线程与本地调试

原项目为了保证ui和cpu独立执行,引入了pthread库来将cpu单独一个线程执行。尽管emscripten官网声称目前他们已经支持了pthread,使用-s USE_PTHREAD选项进行编译即可。但在我实际编译出来的web程序中,线程无法被成功创建。因此我需要把原来的多线程模拟器修改为单线程的,减小这个问题的复杂度。

这个减法需要我对模拟器的组件配合有更深的认识,因此我需要对它进行本地调试。最终选择了macOS的lldb编译器进行调试,比我想象的要简单,安装vscode的CodeLLdb插件,编写一下项目的调试配置文档就能顺利运行了:

.vscode/launch.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "(lldb) Launch",
            "type": "cppdbg",
            "request": "launch",
            "program": "${workspaceFolder}/CBoy/CBuild/gbemu/gbemu",
            "args": [],
            "stopAtEntry": false,
            "cwd": "${fileDirname}",
            "environment": [],
            "externalConsole": false,
            "MIMode": "lldb"
        }
    ]
}

.vscode/tasks.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{
    "version": "2.0.0",
    "tasks": [
        {
            "label": "cmake",
            "type": "shell",
            "command": "cmake",
            "args": [
                "../"
            ],
            "options": {
                "cwd": "${fileDirname}/CBoy/CBuild"
            },            
        },
        {
            "label": "make",
            "type": "shell",
            "command": "make",
            "args": [],
            "options": {
                "cwd": "${fileDirname}/CBoy/CBuild"
            }, 
        },
        {
            "label": "build",
            "dependsOn":["cmake", "make"]
        },
    ],
}

调试窗口

web端无法像本地一样同时渲染两个窗口,因此在emscripten编译时,取消tile的debug窗口输出,发现游戏窗口可以初步展示,游戏可以初步玩,但是画面大小和帧率还不满意

ui.c

1
2
3
4
#ifndef EMSCRIPTEN
    SDL_CreateWindowAndRenderer(16 * 8 * scale, 32 * 8 * scale, 0, 
        &sdlDebugWindow, &sdlDebugRenderer);
#endif

调试输出

在emscripten编译时删除printf输出,大幅提高了游戏帧率(猜想是浏览器运行机制限制了内存量,堆积的printf输出占用内存,严重影响了模拟器的运行)

io.c

1
2
3
#ifndef EMSCRIPTEN
    printf("UNSUPPORTED bus_write(%04X)\n", address);
#endif

改写主循环

为了适应emscripten的调用形式,将ui.c的内容移到gbemu/main.c中,并改写主循环。原理有待研究,好像与js的回调机制有关或类似

main.c

1
2
3
4
5
6
7
#ifdef EMSCRIPTEN
    emscripten_set_main_loop(loop, 0, 1);
#else
    while(!ctx.die) {
        loop();
    }
#endif

未来计划

  • 优化游戏页面
    • 编写一个index.html长期用,替换目前每次编译输出的gbemu.html,使得index.html可以调用编译输出的的wasm和js文件。在index.html中提示操作方法等
  • 优化游戏体验
    • 目前web端按键有时候需要按两次才有反应,帧率目测也没有达到60FPS,需要进一步研究单线程版的模拟器
  • 实现MBC
    • 支持塞尔达传说这种ROM容量超过32KB的游戏
  • 支持原项目的两个输出窗口
  • 研究一下emscripten改写主循环的原理
  • 比较wasm版和原版的性能

参考资料

主要参考方案

Built with Hugo
Jimmy 设计的 Stack 主题