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側に全ての処理を任せてるイメージです。
#[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(())
}
}
const pre = document.getElementById("game-of-life-text");
// js側からはrenderを叩くだけ。
pre.textContent = universe.render();
それを次の実装ではjs側からwasmのメモリを参照して各セルの生死の状態を確認し、js側で描画するといった形にリファクタします。
具体的にはこんな感じでwasmのメモリを参照しています。
#[wasm_bindgen]
impl Universe {
// cellsには各セルの生死状態(0 or 1)が入った配列。それのポインタを返す
pub fn cells(&self) -> *const Cell {
self.cells.as_ptr()
}
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のメモリから取り出した各セルの情報を元に画面へ描画します。
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が気になっている人はこのチュートリアルをやってみてはどうでしょうか!