wasmに入門してみる【3】

にあえん

August 9, 2023

前回の続きです。

ライフゲームの実装(Rust側)

うっすらイメージがついてきたところで実装に入ります。

まずは、src/lib.rsの内容を以下の実装で上書きします。

use wasm_bindgen::prelude::*;

// セルの状態を表す列挙型を定義
#[wasm_bindgen]
#[repr(u8)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cell {
    Dead = 0,
    Alive = 1,
}

#[repr(u8)]を定義すると、Enumのプロパティが1バイトとして表現できます。

また、生存を1として表現することで、周囲のセルの生存状況を単純な加算で取得することができます。

続けてライフゲームの盤上である宇宙を表現します。

// ライフゲームの盤上(宇宙)の表現を行う構造体
#[wasm_bindgen]
pub struct Universe {
    width: u32, // 横幅
    height: u32, // 縦幅
    cells: Vec<Cell>, // 盤上の生存状況
}

特定の行と列にアクセスするために、前回説明した式を実装します。

index(row, column, universe) = row * width(universe) + column

これは宇宙の実装として用意します。

impl Universe {
    fn get_index(&self, row: u32, column: u32) -> usize {
        (row * self.width + column) as usize
    }
}

また、セルの次の状態を計算するため、生存しているセルをカウントする関数もUniverseに実装してあげます。

impl Universe {
    // ...中略...
    fn live_neighbor_count(&self, row: u32, column: u32) -> u8 {
        let mut count = 0;
        for delta_row in [self.height - 1, 0, 1].iter().cloned() {
            for delta_col in [self.width - 1, 0, 1].iter().cloned() {
                if delta_row == 0 && delta_col == 0 {
                    continue;
                }

                let neighbor_row = (row + delta_row) % self.height;
                let neighbor_col = (column + delta_col) % self.width;
                let idx = self.get_index(neighbor_row, neighbor_col);
                count += self.cells[idx] as u8;
            }
        }
        count
    }
}

ちょっととっつきづらいですが、図で確認してみます。

ここでは、以下の図の6のセルでこの関数を実行したと仮定します。

この場合、6のrowは1、columnは1になります。

関数の最初のforループで、delta_row: self.height - 1、delta_col: self.width - 1なので、delta_row: 3, delta_col: 3になります。

        for delta_row in [self.height - 1, 0, 1].iter().cloned() {
            for delta_col in [self.width - 1, 0, 1].iter().cloned() {

次の行は一旦無視して、neighbor_rowとneighbor_colには何が入るでしょう?

変数を代入してあげると、このようになります。

                let neighbor_row = (1 + 3) % 4; // 0
                let neighbor_col = (1 + 3) % 4; // 0

つまり、最初のループではrow: 0, col: 0の状態を取得します。

続けて、次のループではdelta_row: 3, delta_col: 0になると

                let neighbor_row = (1 + 3) % 4; // 0
                let neighbor_col = (1 + 0) % 4; // 1

なので、row: 0, col: 1の状態を取得します。

ここまで来ればお気づきかと思いますが、次のループではdelta_row: 3, delta_col: 1なのでrow: 0, col: 2になります。

                let neighbor_row = (1 + 3) % 4; // 0
                let neighbor_col = (1 + 1) % 4; // 2

左から右へちゃんと移動していっていますね。

さて、その次はdelta_rowが0になります。

delta_row: 0, delta_col: 3になるので、計算式は

                let neighbor_row = (1 + 0) % 4; // 1
                let neighbor_col = (1 + 3) % 4; // 0

となり、row: 1, col: 0の状態を取得します。

改行してちゃんと隣接するセルを取得していますね!

さて、次のループではdelta_row: 0, delta_col: 0ですが、これを計算すると

                let neighbor_row = (1 + 0) % 4; // 1
                let neighbor_col = (1 + 0) % 4; // 1

row: 1, col: 1となります。

しかし、自身のセル状態に関しては取得しなくても良いので、ifでどちらも0の場合はループしないようにしています。

                if delta_row == 0 && delta_col == 0 {
                    continue;
                }

このように計算していくと、ループが終わる頃には以下のセルの状態を合算したcountが取得できます。

キレイに中心以外のセルを取得していますね!

では宇宙の端っこのセルの周りの生存状況を取得する場合はどうなるでしょう?

delta_row: 3, delta_col: 3は変わらないので、rowcolumnのところだけ変えます。

                let neighbor_row = (0 + 3) % 4; // 3
                let neighbor_col = (0 + 3) % 4; // 3

最初のループではどちらも3になりました。

ということはここのセルを取得します。

あれれ〜?おかしいぞ〜?

端っこからセルの周りの生存状況を取得しようとしたら、自身の周りとは関係のないセルを見てしまいました。

まぁ、チュートリアルだし、厳密にやらなくてもいいからこうなっているのかな…と、とりあえず納得しておきます。

次のステップに進むときにチュートリアル読んでいたら、何気に重要なことが書いてありました。

Now we have everything we need to compute the next generation from the current one! Each of the Game’s rules follows a straightforward translation into a condition on a match expression. Additionally, because we want JavaScript to control when ticks happen, we will put this method inside a #[wasm_bindgen] block, so that it gets exposed to JavaScript.

和訳:これで、現在の世代から次の世代を計算するのに必要なものはすべて揃った!ゲームの各ルールは、マッチ式の条件への素直な変換に従う。さらに、セルのチェックが発生するタイミングをJavaScriptに制御させたいので、このメソッドを#[wasm_bindgen]ブロックの中に置き、JavaScriptに公開する。

Implementing Life - Rust and WebAssembly

え?#[wasm_bindgen]マクロってJSに公開するために使うものだったの??

軽く探した感じ、そんなニュアンスのドキュメントは見つかりませんでした。。

まぁとにかく、セルの状態確認の実行はJS上でやりたいので、先程とは別の実装を作ります。

// JSに公開するためつけるwasm_bindgenマクロ
#[wasm_bindgen]
impl Universe {
    pub fn tick(&mut self) {
        let mut next = self.cells.clone();

        for row in 0..self.height {
            for col in 0..self.width {
                let idx = self.get_index(row, col);
                let cell = self.cells[idx];
                let live_neighbors = self.live_neighbor_count(row, col);

                let next_cell = match (cell, live_neighbors) {
                    // ルール1:隣接する生きているセルが2つより少ない生細胞は、過疎が原因で死ぬ。
                    (Cell::Alive, x) if x < 2 => Cell::Dead,
                    // ルール2:隣接セルが2~3個ある細胞は、次の世代まで生き続ける。
                    (Cell::Alive, 2) | (Cell::Alive, 3) => Cell::Alive,
                    // ルール3:隣接セルが3個以上生きている場合、過密で死ぬ。
                    (Cell::Alive, x) if x > 3 => Cell::Dead,
                    // ルール4:生きた隣接セルをちょうど3つ持つ死んでるセルは、生殖によって生きたセルになる。
                    (Cell::Dead, 3) => Cell::Alive,
                    // 他のすべてのセルは同じ状態のままである。
                    (otherwise, _) => otherwise,
                };

                next[idx] = next_cell;
            }
        }

        self.cells = next;
    }

    // ...
}

続けて死んだセルと生きているセルを描画するため、UniverseにDisplayトレイトを継承させます。

use std::fmt;

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 {
                let symbol = if cell == Cell::Dead { '◻' } else { '◼' };
                write!(f, "{}", symbol)?;
            }
            // 行の終わりに改行を出力する
            write!(f, "\n")?;
        }

        Ok(())
    }
}

最後に、盤上のセルの初期状態を定義したnew関数と、Displayトレイトで実装したto_string関数を呼び出すrender関数を定義します。

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

    pub fn new() -> Universe {
        let width = 64;
        let height = 64;

        let cells = (0..width * height)
            .map(|i| {
                if i % 2 == 0 || i % 7 == 0 {
                    Cell::Alive
                } else {
                    Cell::Dead
                }
            })
            .collect();

        Universe {
            width,
            height,
            cells,
        }
    }

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

これで一度wasm-pack buildでビルドしてみます。

% wasm-pack build
[INFO]: 🎯  Checking for the Wasm target...
[INFO]: 🌀  Compiling to Wasm...
   Compiling wasm-game-of-life v0.1.0 (/home/username/Project/personal/wasm-game-of-life)
warning: function `set_panic_hook` is never used
 --> src/utils.rs:1:8
  |
1 | pub fn set_panic_hook() {
  |        ^^^^^^^^^^^^^^
  |
  = note: `#[warn(dead_code)]` on by default

warning: `wasm-game-of-life` (lib) generated 1 warning
    Finished release [optimized] target(s) in 0.44s
[INFO]: ⬇️  Installing wasm-bindgen...
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: Optional fields missing from Cargo.toml: 'description', 'repository', and 'license'. These are not necessary, but recommended
[INFO]: ✨   Done in 1.27s
[INFO]: 📦   Your wasm pkg is ready to publish at /home/username/Project/personal/wasm-game-of-life/pkg.

無事ビルドできましたね。

src/utils.rsモジュールはもう使用しないので、削除してしまいます。

rm src/utils.rs

src/lib.rsにあったutilsの宣言も削除します。

ライフゲームの実装(JS側)

ではフロント側の実装をしていきます。

まずwww/index.htmlbodyに以下の要素を追加します。

<pre id="game-of-life-canvas"></pre>

スタイル調整のためにheadにスタイルタグを入れます。

<style>
  body {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
  }
</style>

続けて、先程実装したUniverseを初期化します。

www/index.jsの元の実装を削除して、以下のように実装します。

import { Universe } from "wasm-game-of-life";

const pre = document.getElementById("game-of-life-canvas");
const universe = Universe.new();

最後に、レンダリング用の関数を実装して終わりです。

const renderLoop = () => {
    let txt = universe.render();
    console.log(txt);
    pre.textContent = universe.render();
    universe.tick();
    // アニメーションを描画するための関数
    // https://developer.mozilla.org/ja/docs/Web/API/window/requestAnimationFrame
    requestAnimationFrame(renderLoop);
};

requestAnimationFrame(renderLoop);

これで完成したようです。

起動してみます。

cd www
npm start

できました!!!

かなりいい感じです。

まとめ

一旦記号で出力することができました!

次回はキャンバスで描画するように実装を修正します。

乞うご期待。