k41531

Rust 🦀 と Wasm のライフゲーム

本記事は、筆者がRustとWasmを学ぶために以下のドキュメントを読み進めた過程を記すものです。ライフゲームがどんなものかについては触れず、実装だけ行います。また、実装の詳細についても下記リンクのままなので全ては掲載致しません。 https://rustwasm.github.io/docs/book/introduction.html#rust--and-webassembly-

ライフゲームの境界条件

ライフゲームを実装する際に、最初に考えなければいけないのが境界条件だと思います。無限の宇宙を作るのか、有限の宇宙を作るのか。今回は、有限のリソース内で無限の宇宙を再現するために周期的な宇宙(右端にいくと左端から戻ってくる)で作っていきます。

RustとJSの境界

JSはメモリ管理機能として、garbage-collected-heapを持っています。一方でWasmは、Rustの値を保存する線形メモリ空間を保持しています。
Wasmの方からgarbage-collected-heapに直接アクセスすることはできません。
JSからは、線形メモリ空間にアクセスすることができますが、やり取りできるのはスカラー値(u8,i32,f64,etc...)のみです。

wasm_bindgenには、この境界を超えて複合構造を扱う方法が定義されています。例えば、Rustの構造体をJSで使いやすくするために、構造体をボックス化し、そのポインタをJSのクラスでラップしたり、RustからJSオブジェクトのテーブルへのインデックスを作成できます。要するにインターフェイスをデザインするためのツールです。

WasmとJSのインターフェイスを設計する際の注意

  1. Wasmの線形メモリ空間へのコピーや、外部へのコピーを最小限にする。
  2. シリアライズやデシリアライズを最小限にする。 優れたJavaScript↔WebAssemblyのインターフェイス設計は、大きくて寿命の長いデータ構造を、Wasmの線形メモリ空間に潜むRust型として実装し、JSには不透明なハンドルとして公開することが多いようです。

今回のプロジェクトでの注意点

  1. Wasmの線形メモリに、tickごとに宇宙全体を読み書きすることは避けたい。
  2. 宇宙の各セルに、オブジェクトを割り当てたくない。
  3. 各セルの読み書きのために、境界を超えたくない。
  • 宇宙は、Wasmの線形メモリ空間上でフラットな配列で表現する。0が死んだセルで1が生きているセル。
  • 宇宙は、StringとしてRustからJSに渡す。のちに<canvas>にレンダリングできるようにする。
  • [代替案]:変化したセルの差分だけを渡すようにするとより効率的ですが、実装が若干難しい。

Rustでの実装

Rust Implementation
コードはドキュメントに記載されているので、一部だけピックアップして紹介していきます。

セルを列挙型で定義する。

#[wasm_bindgen]
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cell {
    Dead = 0,
    Alive = 1,
}

#[repr(u8)は型レイアウトができるアトリビュート

型レイアウト

型のサイズやアライメント、フィールドの相対的なオフセットのこと。列挙型の場合は、識別子がどのように配置され解釈されるかを指定できる。

宇宙を構造体で定義する。

#[wasm_bindgen]
pub struct Universe {
    width: u32,
    height: u32,
    cells: Vec<Cell>,
}

宇宙のメソッドを定義する

  • 行と列を配列の添字に変換する関数
  • 隣合う生きているセルを数える関数
  • 宇宙の状態を更新する関数
  • 宇宙の状態を表示する関数 render()
  • std::fmt::Displayのfmt関数(出力した時の表示方法を定義するためのもの)

宇宙の関連関数を定義する

  • インスタンスを生成する関数 new()

JSへのレンダリング

index.htmlに宇宙を描画するための要素を用意します。

<body>
  <pre id="game-of-life-canvas"></pre>
  <script src="./bootstrap.js"></script>
</body>

index.jsでwasm-game-of-lifeからUniverseをインポートする。
idを使って表示するタグを取得し、 Universeのインスタンスから得られる文字列を描画する。

import { Universe } from "wasm-game-of-life";
const pre = document.getElementById("game-of-life-canvas");
const universe = Universe.new();

const renderLoop = () => {
    pre.textContent = universe.render();
    universe.tick();
    requestAnimationFrame(renderLoop);
};
requestAnimationFrame(renderLoop);

メモリから直接描画する

JavaScriptは、直接WebAssemblyの線形メモリ空間に触れるので、そこから直接データを取得し、canvasに描画する。

<body>
  <canvas id="game-of-life-canvas"></canvas>
  <script src="./bootstrap.js"></script>
</body>

Rustにあるcellsの横幅と高さ、そしてcellsのポインタにアクセスできるようにします。

/// Public methods, exported to JavaScript.
#[wasm_bindgen]
impl Universe {
    // ...

    pub fn width(&self) -> u32 {
        self.width
    }

    pub fn height(&self) -> u32 {
        self.height
    }

    pub fn cells(&self) -> *const Cell {
        self.cells.as_ptr()
    }
}

index.jsの方も書き換えます。
こちらも長いので詳細はドキュメントを参照Rendering to Canvas Directly from Memory
特筆すべきコードは、

import { memory } from "wasm-game-of-life/wasm_game_of_life_bg";

WebAssemblyの線形メモリにmemoryを通して直接アクセスすることができます。

const cellsPtr = universe.cells();
const cells = new Uint8Array(memory.buffer, cellsPtr, width * height);

Uint8Array は型付き配列で、 8ビット符号なし整数値の配列を表します。このコードの場合、引数にバッファを渡しているので、指定されたバッファを表示するための型付配列ビューが生成されます。詳細は以下のリンクを参照
https://developer.mozilla.org/ja/docs/Web/JavaScript/Typed_arrays
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array/Uint8Array

練習問題

Exercises [Q] ハードコーディングされている初期宇宙を、乱数を使って生成する。
Rustのrandクレートを試してみたが、うまくできませんでした。

error: the wasm32-unknown-unknown target is not supported by default, you may need to enable the "js" feature. For more information see: https://docs.rs/getrandom/#webassembly-support

https://docs.rs/getrandom/#webassembly-support

js-sysクレートを使用して、JavaScriptの関数であるMath.randomをインポートすると乱数が使える。

[Q] 今は1セルを1バイト(8ビット)で表現していますが、セルは生か死の2つの状態しか持たないので1ビットで表現できます。各セルを1ビットで表現できるようにリファクタリングをする。

fixedbitsetクレートのFixedBitSet型をVec<Cell>の代わりに使う。