🌵

xsystem4ゲームエンジンのEmscripten対応

2024/05/06に公開

xsystem4ゲームエンジンをEmscriptenでビルドしてブラウザで動かすために何をしたかの記録。

この成果物を使ってPWAを作る話はこちらの記事に書いた。
https://zenn.dev/kichikuou/articles/ef05c46e42fd67

xsystem4

https://github.com/nunuhara/xsystem4
xsystem4は、アリスソフト(アダルトゲームブランド)で2002年ごろから使われている[1]ゲームエンジンSystem4のオープンソース実装。C言語で書かれている。昔たくさんあった移植エンジンの現代版。

Emscripten対応

xsystem4はSDL2 + OpenGL ES 3.0で実装されていて、どちらもEmscriptenで使えるので基本的にはそのままビルドできるが、いくつか考慮しなければならないことがある。

メインスレッドの非同期化

xsystem4はメインループを自分で持っていて、VMを実行する関数を呼び出すとゲームが終了するまで返ってこない。しかしブラウザで動かすためには、ブラウザから呼ばれるコールバック関数の中で少し処理を行なってはブラウザに制御を返す、というような構造にしなければならない。xsystem4は関数呼び出しの深いところで入力待ちや画面更新をするので、イベント駆動的に書き直すのは厳しい。

またWebの世界ではファイルアクセスもメインスレッドからは非同期的にしかできないので、C言語で普通にファイルを読むコードを動かそうとすると困ったことになる。あらかじめメモリ上にファイルを読み込んでおけば同期的にアクセスできるが、ファイルサイズが大きい場合は現実的でない。

これらの問題に対する対策としては、メインループを別スレッドで動かすかAsyncifyを使うか、ということになる。

案1 - メインループを別スレッドで動かす

最近のブラウザにはOffscreenCanvasという機能があって、サブスレッドでGLコンテキストに描画した内容を<canvas>に表示できる。

Emscriptenで-s PROXY_TO_PTHREAD=1-s OFFSCREENCANVAS_SUPPORT=1をつけてビルドするとこれを使える…はずなのだが、実行すると下のようなエラーになる。

Failed to execute 'getContext' on 'HTMLCanvasElement': Cannot get context from a canvas that has transferred its control to offscreen.

この問題はSDL3では解決済みなので、SDL2に修正をチェリーピックして自前でビルド。さらにSDL2のオーディオもサブスレッドからは動かないので一旦無効化すると起動するようになった…が、デバッガで一時停止するまで画面が出ない。

ドキュメントにはemscripten_webgl_commit_frame()を呼ぶと<canvas>が更新されるようなことが書かれているが、実際にはこの関数は何もしない!
https://github.com/emscripten-core/emscripten/blob/3.1.57/src/library_html5_webgl.js#L251-L254

調べてみると、キャンバスに描画内容を反映させるAPI OffscreenCanvasRenderingContext2D.commit()問題が指摘されて仕様から削除され、現在はサブスレッドでもイベントループに制御を戻さないと描画内容は反映されないようだ。

これではメインループをサブスレッドに持ってくる意味がないので、諦めてAsyncifyを使う方針に切り替えることにする。

案2 - Asyncify

Emscriptenの-s ASYNCIFY=1オプションを使うと、同期的に書かれたコードを非同期的に動くように変換してくれる。例えば関数呼び出しの深いところでemscripten_sleep()のような非同期関数を呼ぶと、その時点までの実行コンテキスト(呼び出しスタックや一時変数)を退避してJavaScriptに制御を戻し、後からJavaScript側でコールバックを呼ぶと元の状態を復元して実行を再開できる。

欠点はプログラム変換のためバイナリサイズが大きくなり遅くなることだが、18年前[2]のゲームを動かす分には許容範囲内だろう。

ちなみに、-s ASYNCIFY=2[3]を指定すると、JSPIを使ってプログラム変換なしに非同期に動くWebAssemblyを出力してくれる。JSPIは現在Chromiumでのみ実験的に実装されている。

ファイルアクセス

AsyncifyによりC言語から非同期関数を呼べるようになったが、Emscripten組み込みのファイルシステムは非同期に動くようにはできていない[4]ため、前述の通りファイル全体をあらかじめメモリに読み込んでおかなければならない。xsystem4は数百MB〜GB単位のファイルを扱う必要があるため、これは望ましくない。

幸いxsystem4ではアーカイブファイルのアクセスは抽象化されているので、そのバックエンドとしてEmscriptenのファイルシステム層を経由せずにファイルを読む関数をJavaScriptで書いてやれば良い。

アーカイブ以外の細々としたファイルは起動時にEmscriptenのMEMFSに置き(つまりメモリ上に置かれる)、fopen()などで普通にアクセスできるようにしておく。ただしムービーファイルはまた別扱いで、ムービー再生中だけEmscriptenのヒープにロードするようにしている。これはサイズがやや大きいのと、オーディオスレッド(後述)からもアクセスする必要があるがEmscriptenのファイルシステムはマルチスレッド対応でないため。

オーディオ

SDL2のオーディオはEmscriptenではScriptProcessorNodeを使っていて、メインスレッドで定期的に呼ばれるオーディオコールバックで音声サンプルを返す必要がある。メインスレッドが重い処理を実行中でコールバックが時間内に起動できないと、音声が乱れることになる。

対策はいくつか考えられる。

  1. 重い処理の途中で時々emscripten_sleep()して、ブラウザに制御を戻すようにする。
  2. オーディオ処理を(ミキシング等を含めて)WebAudio APIで行う。鬼畜王 on Web はこの方式を採っている。
  3. EmscriptenのWasm Audio Worklets APIを使う。

1はsleepを入れ損なうと音声乱れが起きることになる。System4は命令の粒度がバラバラなので、どのようにsleepを入れるかの判断は難しい。

2はEmscripten以外と共通化できないコードをそれなりの量書く必要がある。また、WebAudioの仕様によって柔軟性が制限される。例えばWebAudioでは、音声を指定回数だけループした後に止めるような指定はできない。

そこで今回は3のWasm Audio Worklets APIを採用するにした。これを使うとAudioWorklet上にメインスレッドと同じWebAssemblyモジュールと共有メモリをロードしてくれて、要はアプリケーションからは同一メモリ空間で動くオーディオスレッドのように見えるようにしてくれる。

SDL2のオーディオAPIを使っている部分を、Wasm Audio Worklets APIを使うように書き換える。
https://github.com/kichikuou/xsystem4/commit/9a7dc6797b620d96ae8d89cf479c1b1cfd7c5479

ところでWasm Audio Worklets APIは、EmscriptenのWasm Workers APIというpthreadsとは別のスレッドAPIを使って実現されている。これは-s WASM_WORKERS=1オプションで有効になるが、SDL2などemscripten portsのいくつかのライブラリはこれを認識せずシングルスレッド用にビルドされてしまう。正しくマルチスレッド用にビルドするにはこのようなパッチを当てるか、pthreadsも有効にしてビルドすると良い。今回は単に-sWASM_WORKERS=1-pthreadを両方指定してビルドすることにした。

libffi

System4は外部DLLを呼び出す機能を持っており、xsystem4ではこれをlibffiを使って実現している。といっても動的リンクしているわけではなく、VMのスタックから取り出した引数を適切な規約で渡して関数を呼ぶために使っている。
https://github.com/nunuhara/xsystem4/blob/f47e5e5a82d14b8c6c9021815abaac2e280e2ab6/src/ffi.c#L200-L265

実はlibffiは去年リリースの3.4.5からemscriptenをサポートしていて、上のコードはビルドできる。が、JavaScript経由で動的呼び出しするところでAsyncifyが考慮されていないため、ffi_call()の内部で非同期関数を呼び出すと実行時エラーになってしまう。

libffiにAsyncfyサポートを実装することも考えたが、ASYNCIFY=1ASYNCIFY=2(JSPI)で別の対応が必要になるのと、そもそもxsystem4ではlibffiが絶対に必要なわけではないので、libffi依存を取り除くことにした。
https://github.com/kichikuou/xsystem4/commit/2be7745fbafa3b5607a02a4a638771159e060847#

呼び出されうる関数のすべての型を列挙しておき、そこから以下のようなswitch文をコード生成している。

static void ffi_call(const enum hll_signature *cif,
                     void *fun,
                     union vm_value *r,
                     void **args)
{
    switch (*cif) {
    ...
    case HLL_SIG_I:  // () -> int
        r->i = ((int(*)(void))fun)();
        break;
    case HLL_SIG_II:  // (int) -> int
        r->i = ((int(*)(int))fun)(((union vm_value*)args[0])->i);
        break;
    case HLL_SIG_IIF:  // (int, float) -> int
        r->i = ((int(*)(int, float))fun)(((union vm_value*)args[0])->i, ((union vm_value*)args[1])->f);
        break;
    ...
    }
}

その他

細かい部分として、厳格なWebGL2では他のOpenGL ES環境でなんとなく動いていた所が動かなくなったり、
https://github.com/kichikuou/xsystem4/commit/3553f5cdf988251270f53bd153b9753281c065c2

emscripten_sleep()を適当に入れてやらないとフリーズしたりすることがあった。
https://github.com/kichikuou/xsystem4/commit/50b59c8affc5c61feae9e17cd5eae5e5d10d7116

脚注
  1. ある時点からSystem4という名前は使われなくなったが、2024年現在の最新作でもこのエンジンの上位互換版が使われている。 ↩︎

  2. 『戦国ランス』の発売は2006年 ↩︎

  3. Emscripten 3.1.59で -s JSPIオプションで置き換えられた ↩︎

  4. JSPIとWasmFSの組み合わせは非同期をサポートするらしいが、これらが一般に使えるようになるのはまだ先の話 ↩︎

Discussion