rust + WebAssembleyでライフゲーム


rustとWebAssembleyで何か作ろうと思ったんですが、何も思い浮かばず、チュートリアルをやってみました。

チュートリアルをやるとこんな感じのライフゲームが作れます。

デモはこちらにあります。
https://wasm-rust-game-of-life.netlify.app/

コードはこちらです。
https://github.com/pokotyan/wasm-game-of-life

WebAssembley

ブラウザ上で動かせるバイナリです。C、C++、Rust、Go、Kotlin、AssemblyScript(TypeScriptのサブセット)などからコンパイルして生成することができます。
いろんな言語からwasmを作れますが、GCを備えた言語だとコンパイル後のwasmファイルが大きくなってしまいます。RustのようなGCがない言語だとwasmのサイズが小さくなるため、ページ読み込み速度も改善するようです。参考

wasmの現状の主な用途は重い計算処理などをオフロードさせるために使われてるみたいなので、GCがないかつ、メモリ安全なRustは相性いいんじゃないかなと思いました。

ツールチェイン

rustでWebAssembleyをやる際のツールチェインはこちらの記事がよく纏まっていました。大きく分けてemscripten系統とwasm-bindgen系統に大別されるみたいです。(wasm-bindgenの方が新しい)

冒頭のチュートリアルではwasm-bindgen系統であるwasm-packを利用しています。
wasm-bindgenはRust(wasm)とjs間がやりとりできるようなインターフェースを作ってくれるツールですが、wasm-packはそれをさらにwrapして、webpackで読み込めるようにしたりとか、npmに簡単にpublishできるようなコマンドを用意してくれたりしてます。

ボイラープレート

チュートリアルでは以下のボイラープレートを用いて、さらに環境構築を簡単にしています。

rust(wasm)側のテンプレート

wasm-pack-template
https://github.com/rustwasm/wasm-pack-template

wasm-packを活用したボイラープレートです。このテンプレートをcargo-generateコマンドを用いて環境構築します。
rustのパッケージマネージャであるcargoはいろいろなサブコマンドが用意されており(自分で作ることも可能)、cargo-generateを使うと、指定したgitリポジトリのテンプレートを元にRustのプロジェクトを新規作成できます。

なので、これを一発打つだけで、Rust側の環境構築は終わりです。
(もちろん事前にrustやcargoのインストールは必要ですが、そこもチュートリアル上でちゃんと書かれてます)

$ cargo generate --git https://github.com/rustwasm/wasm-pack-template

フロント側のテンプレート

create-wasm-app
https://github.com/rustwasm/create-wasm-app

このテンプレートを npm init wasm-app で利用すると hello-wasm-pack というwasmのnpmパッケージを利用する最低限のプロジェクトが生成されます。

import * as wasm from "hello-wasm-pack";

wasm.greet();

チュートリアルでは、ここのimportを実際に自分でコーディングし、ビルドしたrustのwasmに置き換えながら進めていきます。

他にもあるテンプレート

上記の wasm-pack-template と create-wasm-app のテンプレートは以下のような利用を想定したテンプレートです。

  • wasm-pack-template
    Rustで作ったwasmをnpmにパブリッシュし、npmパッケージにする。

  • create-wasm-app
    npm上にあるwasmをインストールし利用する。

なので、どちらもnpmを介して利用されることを期待したテンプレートです。
そうではなく、モノレポ的に使えるオールインワンなテンプレートも用意されてます。

rust-webpack-template
https://github.com/rustwasm/rust-webpack-template

rust-parcel-template
https://github.com/rustwasm/rust-parcel-template

この二つの違いは名前の通り、バンドラーにwebpackを使うかparcelを使うかくらいの違いだとは思いますが、これを使えば一瞬でRust + WebAssembley + jsの環境構築ができるので、wasmをちょっと試したい場合にはすごい便利そうです。

ライフゲーム

ライフゲームでは各セルが生死の状態を持ちます。
そしてそれぞれのセルが以下の4つの条件によって生死を繰り返します。

  • 生きている隣人が2つ以下の生細胞は、過疎化が原因であるかのように、死ぬ。
  • 2つまたは3つの生きた隣人を持つすべての生細胞は、次の世代に生き続ける。
  • 3つ以上の生きている隣人を持つ生きている細胞は、過疎が原因であるかのように、死ぬ。
  • 生きている隣人がちょうど3人いる死んだ細胞は、生殖によって生じたかのように、生きている細胞になる。

この世代交代をrequestAnimationFrameの度に実行すると、冒頭に貼ったgifのようになります。
このライフゲームで現れるセルのパターンはいろいろあり、調べると色々出てきます。僕はこのチュートリアルでライフゲームというものを知りましたが、ライフゲームはチューリング完全らしく、論理ゲートを作ってる人もいました

jsからwasmのメモリを参照する

チュートリアルでは、初めは簡単な実装でライフゲームを動かし、それをリファクタしていく形で進んでいきます。

最初の実装はrustのwasm側で「セルの生死の状態によって ◼ もしくは ◻ を出力する」というコードを書きます。そしてjs側からはその関数を呼び出すだけです。js側からwasm側に全ての処理を任せてるイメージです。

lib.rs
#[wasm_bindgen]
impl Universe {
    // ...

    pub fn render(&self) -> String {
        self.to_string()
    }
}

impl fmt::Display for Universe {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        for line in self.cells.as_slice().chunks(self.width as usize) {
            for &cell in line {
                // cellの状態によって表示を変える
                let symbol = if cell == Cell::Dead { '◻' } else { '◼' };
                write!(f, "{}", symbol)?;
            }
            write!(f, "\n")?;
        }

        Ok(())
    }
}
index.js
const pre = document.getElementById("game-of-life-text");

// js側からはrenderを叩くだけ。
pre.textContent = universe.render();

それを次の実装ではjs側からwasmのメモリを参照して各セルの生死の状態を確認し、js側で描画するといった形にリファクタします。

具体的にはこんな感じでwasmのメモリを参照しています。

lib.rs
#[wasm_bindgen]
impl Universe {
    // cellsには各セルの生死状態(0 or 1)が入った配列。それのポインタを返す
    pub fn cells(&self) -> *const Cell {
        self.cells.as_ptr()
    }
index.js
import { Universe, Cell } from "wasm-game-of-life";
import { memory } from "wasm-game-of-life/wasm_game_of_life_bg";

  // wasmのメモリ内からcellsのメモリ内容を取得する。
  // cellsPtrにはcellsの先頭アドレスが入っている。cellsの先頭からwidth * height分の範囲のメモリ内容を返す。
  const cellsPtr = universe.cells();
  const cells = new Uint8Array(memory.buffer, cellsPtr, width * height);

そして、wasmのメモリから取り出した各セルの情報を元に画面へ描画します。

index.js
  ctx.beginPath();

  for (let row = 0; row < height; row++) {
    for (let col = 0; col < width; col++) {
      const idx = getIndex(row, col);

      // cellの状態によって表示を変える
      ctx.fillStyle = cells[idx] === Cell.Dead ? DEAD_COLOR : ALIVE_COLOR;

      ctx.fillRect(
        col * (CELL_SIZE + 1) + 1,
        row * (CELL_SIZE + 1) + 1,
        CELL_SIZE,
        CELL_SIZE
      );
    }
  }

  ctx.stroke();

今まで、jsでArrayBufferを扱うコードを書くことはそんななかったため、あまりわかってないですが、ここら辺のメモリの扱いがWebAssembleyをやってく上でのキモだなあという感じがしています。

最後に

僕はもういいかと思ってやってないですが、 以下のような内容もチュートリアルにあります。

  • 生死の判定に8bitも使っているのを1bitで判定するようにリファクタ
  • テストの追加
  • フレームのたびにデバッガーを挟む
  • 一時停止ボタン
  • フレームごとにかかった処理時間の計測
  • ビルドのオプションを見直して、wasmのサイズ削減
  • npmへpublish

そんなに時間もかからず終わるのでWebAssembleyが気になっている人はこのチュートリアルをやってみてはどうでしょうか!