WebAssembly/Emscripten を使ってエミュレーターをブラウザで動かす

WebAssembly を使うとブラウザー上でいろいろな言語のエコシステムが使えて楽しいなと、最近 Rust/WebAssembly で遊んでいたのですが、ふと C もやってみようかなと hello world 的にメガドライブエミュレーター(Genesis-Plus-GX)を移植することに挑戦してみました。

実は Emscripten はかなり前に一度挑戦していたのですが、ブラウザーで動作させる際のビルド周りがなかなか大変で、あまり大きなものは動かすことができませんでした。

再挑戦ということで調査したところ、昨今はビルド周りも整備されていてなかなかいい感じに環境ができあがっているようです。

この記事のソースコードは github で公開しています。解説よりも、ソースを見ていただいたほうが早いかもしれません。 🙂

https://github.com/h1romas4/wasm-genplus

Genesis-Plus-GX WebAssembly porting (work in progress)

Firefox で動作を確認しています。

Emscriten + webpack ビルド

よい製作にはよいビルドということで、JavaScript 系は webpack を使ってビルドし Emscriten/wasm を読み込むようにしています。

当初は emcc-loader という webpack の extention を使って、webpack から emcc(Emascriten のコンパイラ)を呼び出す形にしていたのですが、現在メンテナンスされていないようで Emascriten 1.39.0 では emcc が出力する JavaScript のグルーコードがエラーとなりうまく wasm をロードすることができませんでした。

Module.ENVIRONMENT has been deprecated. To force the environment, use the ENVIRONMENT compile-time option 

どうやら emcc-loader が生成するコードが指定している環境変数指定が非推奨となったということのようです。emcc-loader の次の場所(ENVIRONMENT: 行)をコメントアウトすると動作させることができました。

/**
 * Builds a loader script.
 */
async buildLoaderScript(baseScriptContent : string, options : LoaderOption) {
    const config = {
        ENVIRONMENT: options.environment,
    };

動かせるようになったものの、emcc-loader はプログラムに修正がかかるとフルビルドになる動作となり、少し大きめのプロジェクトだと時間がかかるため、今回は emcc-loader は諦めて C の部分は通常の cmake / make でビルドするようにしています。

この場合で emcc で出力される wasm/JavaScript のグルーコードを webpack で読み込むときは、リンカーオプションを次のように指定しモジュール化してあげます。

add_compile_flags(LD
    "-s DEMANGLE_SUPPORT=1"
    "-s ALLOW_MEMORY_GROWTH=1"
    "-s MODULARIZE=1"
)

この上で自分の JavaScript から、emcc が出力したグルーコード JS を import して次のようにすると、 wasm が async でロードされ wasm モジュールが操作できるようになります。

import wasm from './genplus.js';

let gens;

wasm().then(function(module) {
    gens = module;
    gens._init();
    // ...
});

モジュールとなったグルーコードを wasm() などと受けて then 以下で取得します。

WebAssembly とインターフェースする C の関数は次のように定義し、JavaScript からはモジュールから _ 付の関数として呼び出しすることができます。

void EMSCRIPTEN_KEEPALIVE init(void)
{
    // ...
}

また、C側の make についてですが cmake、make ともに Emscripten のラッパーコマンドが準備されていて、これを経由して cmake / make することでコンパイルオプションなどを自動的に調整してくれるようです。

emcmake cmake ..
emmake make

github のほうにビルド手順を記載してあります。

メモリーの共有

WebAssembly 側で malloc したメモリーはモジュールの HEAP*.buffer ビュー経由で JavaScript から見ることができ、JS 側の TypedArray 経由でアクセスできます。

vram = new Uint8ClampedArray(gens.HEAPU8.buffer, gens._get_frame_buffer_ref(), CANVAS_WIDTH * CANVAS_HEIGHT * 4);
uint32_t *frame_buffer;

void EMSCRIPTEN_KEEPALIVE init(void)
{
    // ...
    frame_buffer = malloc(sizeof(uint32_t) * VIDEO_WIDTH * VIDEO_HEIGHT);
    // ...
}

uint32_t* EMSCRIPTEN_KEEPALIVE get_frame_buffer_ref(void) {
    return frame_buffer;
}

エミュレーターで作成した VRAM (uiint32_t) を JS 側で取得して canvas にそのまま描画しています。

ちなみに、この例では C 側は uint32_t ですが、canvas は RGBA を Uint8ClampedArray ビューで扱うためエンディアンで逆になってしまい色がおかしくなりました。。ややはまり。エミュレーター側で ABGR 順の VRAM をつくることで対応しています。

音声

ブラウザ WebAudio API 側は Float32Array の 2チャンネル分離で、エミュレーター側はサンプリングを S16LE で生成するため wasm 側で変換しています。

float_t convert_sample_i2f(int16_t i) {
    float_t f;
    if(i < 0) {
        f = ((float) i) / (float) 32768;
    } else {
        f = ((float) i) / (float) 32767;
    }
    if( f > 1 ) f = 1;
    if( f < -1 ) f = -1;
    return f;
}

また、生成したサンプリングを WebAudio API の createBufferSource で即発声させると、次の発声が途切れてしまうので web-audio-buffer-queue ライブラリを使わせてもらって、少しバッファリングしてから発声するようにしています。

残念ながら iOS Safari ではブラウザの発声規制にかかっているせいか音が鳴りません。一応、クリック後にコンテキストをつくるようにしてみたのですが…

WebAssembly/Rust で似たようなことをやった時は iOS でも発声していたので、要調査。

ソースコードデバッグ

C 側のソースですが、emcc がソースマップ出力に対応しているためブラウザ(Firefox で確認)でデバッグブレイクが可能です。ただし、変数の値などはみることはできないようです。

ソースマップを出力するためには emcc のコンパイルオプションで -g4 を指定し、–source-map-base オプションを指定します。

# source map option (but not working)
#    -g4
#    --source-map-base src/main/c

その上で、ソースコードがブラウザーから見えるように http の領域に配置します。

devServer: {
    inline: true,
    contentBase: [
        path.join(__dirname, '/docs'), // eslint-disable-line
        // for sourcemap - src/main/c
        path.join(__dirname, '/'), // eslint-disable-line
    ],

基本的にはこれで止まるのですが、残念ながら現在 –source-map-base オプションが wasm の場合はうまく効かず、出力がすべてフルパスになってしまうようです。(emcc-loader だといい感じに source-map がでていたので何か方法があるのかもしれません)

とりあえず、出力された source-map のフルパス部分を置換して http から見えるパスにしてあげれば動作します。

WebAssembly/Emascripten 上でプログラムを動作させると、メモリーアクセスに対して境界値チェックが働くパターンがあるようです。実は移植当初 ROM のおしり 0x800000 から SRAM がマッピングされているのに気が付かず(ネイティブだと動いてしまっていた)、wasm がダウンしてしまっていたのですが、ソースコードマッピングすることで場所を特定することができました。

C 側の軽いデバッグの場合は、EM_ASM_ マクロでブラウザーのコンソールに文字列を出力できます。

#include <emscripten/emscripten.h>

uint8_t *rom_buffer;

void console_log() {
    EM_ASM_({
        console.log('genplus_buffer0: ' + $0.toString(16));
    }, buffer[0x100]);
}

なお、wasm 側でエラーがコンソールに出力された場合は、このマクロの出力が消える場合があるようです。(Firefox にて)

Emscriten 移植のこつ

箇条書きにて。

  • C 側はネイティブでも動作を確認する環境をつくりつつ、Emscripten でコンパイルして動作させりるとすると切り分けが早いです。前述の境界値系のエラーが wasm ででた場合はソースをアタッチするといいと思います。
  • WebAssembly の場合、イベントや入出力系は全て JavaScript 側の役目になりますので、エミュレーター系の移植の場合は一貫して JS -> wasm の呼び出しパターンでプログラムを構成すると、処理の粒度的に分かりやすくなりそうです。
  • C のプログラムを動かすというよりも、ビルドや JS とのインターフェース周りを調査するのに時間がかかりました。逆説的には、そこがクリアできれば大抵のものが動かせそうです。

というわけで、WebAssembly はいろいろできて楽しいですね。エミュレーターのほうですがまだコントローラーをつないでないので、Gamepad API で接続してみたいと思います。 🙂

ついに iOS でエミュレーターが動かせる…!

関連

このエントリーをはてなブックマークに追加

コメントを残す

メールアドレスが公開されることはありません。