WebAssemblyのゲームをアセンブリ直書きで作る

左右矢印キーでスタートして移動、降ってくる岩を避けて下さい。

コードは以下。

ブラウザ上のアセンブリ言語ことWebAssemblyChromeFirefoxで動くようになってきたので何か作ろうと思った。普通はUnityとかRustとかのWebAssemblyを出力できるエンジンや言語を使って生成するのだが、せっかくだから直に書いてみた。

WebAssemblyはS式で書ける(wast形式)。

WebAssemblyはスタックマシンなので、2つの値を足す場合、それら値をスタックに順に積んでAddを呼び出す、というふうに書く。

get_local $p
get_local $p
i32.add

でも引数が先に来て後に命令がくるのはあまり直感的な感じはしない。なので同じものをS式で

(i32.add (get_local $p) (get_local $p))

という具合に書けるようになっている。

wastで書かれたコードはブラウザ上で実行可能なwasm形式にして、それをJavaScriptから呼び出す必要がある。wasmを作るにはWebpackローダーのwast-loaderを使う。wast-loaderは内部でwast2wasmを呼び出してwasmを生成してくれる。

JavaScriptとの連携方法は以下に詳しい。

wasmはWebAssembly.instantiateを使ってインスタンスを生成できる。

function instantiate(wastBuffer, imports) {
  return WebAssembly.instantiate(wastBuffer, imports).
    then(result => result.instance);
}

ゲームを作るからには画面への出力やキー入力などを扱いたいが、今のWebAssembly実装はDOMが触れない。

なのでそれらはJavaScript経由で行う必要がある。今回はJavaScriptとWebAssembly双方から触ることのできるMemoryを使って画面上のピクセルやキー状態のやり取りをすることにした。雑な図は以下の通り。

WebAssembly.Memoryを使ってMemoryを確保すればJavaScript上からはUint8Arrayとして見ることができる。そこにキー状態を入れたり、ピクセル状態を取り出してCanvasに反映したりする。

    new WebAssembly.Memory({ initial: 512 / 4 });
    ram = new Uint8Array(instance.exports.ram.buffer);
    document.onkeydown = e => {
      ram[e.keyCode + 256] = 1;
    };
    document.onkeyup = e => {
      ram[e.keyCode + 256] = 0;
    };
...snip...
  for (let i = 0; i < 256; i++) {
    const x = i % 16;
    const y = Math.floor(i / 16);
    if (ram[i] > 0) {
      context.fillRect(x, y, 1, 1);
    }
  }

wast上からはloadやstore命令でMemoryの読み書きができる。

  (memory (export "ram") 1)
...snip...
    (if (i32.load8_u (i32.const 293))

また、wast上の関数をexportしてJavaScriptから呼び出すことができる。これを使ってJavaScript上のrequestAnimationFrameからwast上の$update関数を呼び出す。

function update(instance) {
  instance.exports.update();
...snip...
  requestAnimationFrame(() => update(instance));
}
  (func $update (export "update")

またJavaScript上の関数をimportしてwast上から呼び出すこともできる。これを使ってMath.random()をwast上から呼び出して使う。

  instantiate(game, {
    imports: {
      random: () => Math.random()
    }
  }).then(instance => {
  (func $random (import "imports" "random") (result f32))
...snip...
    (set_local $rnd 
      (i32.trunc_u/f32 (f32.mul (call $random) (f32.const 16))))

ここまで準備できればあとはゲームのロジックをアセンブリ言語で書くだけだ。使える命令群は以下のページにある。

Z80とかでブイブイいわせてた人には楽勝だろう。WebAssemblyは膨大のメモリ、膨大なレジスタがあって浮動小数点数も使えて制御構文もある高級アセンブリ言語のようなものだ。レジスタが枯渇して裏レジスタを使わねば、みたいな葛藤は必要ない。

例えば岩を下にスクロールさせる処理は以下の通り。

    (set_local $from (i32.const 224))
    (set_local $to (i32.const 240))
    (block $slide_break
      (loop $slide
        (set_local $from (i32.sub (get_local $from) (i32.const 1)))
        (set_local $to (i32.sub (get_local $to) (i32.const 1)))
        (i32.store8 (get_local $to) (i32.load8_u (get_local $from)))
        (br_if $slide_break (i32.eqz (get_local $from)))
        (br $slide)
      )
    )

……なんか面倒な気が。プレイヤーを左右に動かすのは以下。

    (if (i32.load8_u (i32.const 293))
      (then 
        (set_global $player_x (f32.sub (get_global $player_x)
          (get_global $player_speed)))
      )
    )
    (if (i32.load8_u (i32.const 295))
      (then 
        (set_global $player_x (f32.add (get_global $player_x)
          (get_global $player_speed)))
      )
    )

やっぱり面倒だ。昨今の高級言語に慣れた身にはえらく煩雑に思える。

あとwastの書き方として変数を読み書きするのにいちいちget_localとか書かないといけないとか定数にもi32.constとかの記述が必要だとかいう問題もある。まあ手書きする想定のものではないからなあ。手書きするんだとするとこの辺を簡単に書けるシンタックスシュガーが欲しいところ。

WebAssemblyのおかげでブラウザ上でも低レベルなアセンブリ言語遊びができるようになったのは大変うれしい。でも現時点だとDOMが触れない制約のせいでJavaScriptに依存しないゲームループ実現が難しく、そのパフォーマンスを十分に享受することができないのが残念。早いところCanvasWebGLと直接やり取りできるようになって欲しい。そうなればブラウザ上の低レベルゲーム制作が楽しめるようになりそう。