🧱

(前編)MDNブロック崩しをJSからRustに移植してWebAssemblyに入門

2023/11/01に公開

趣味の Rust です。MDN ブロック崩しを JavaScript から Rust に移植して WebAssembly に入門したときのメモです。
この文章は前編、後編、中編の 3 つにわけて投稿する予定です。今回は前編です。

前置き

前々から Rust を触ってはいたのですが、WebAssembly 開発を体験したことはありませんでした。Rust を使って何かしらの Web 開発をやってみようと思っていました。

MDN Web Docs には純粋な JavaScript を使ったブロック崩しゲームという、JavaScript と HTML5 Canvas を使ったゲームサンプルがあります。今回はこちらの JavaScript を Rust で書いて WebAssembly として動作させるところまでをやってみます。

Web ブラウザゲームを開発するならば、JavaScript/TypeScript を使った方が軽量で開発もしやすいでしょう(Phaser エンジンもありますし)。ですが、今回はあくまでも Rust + wasm への興味を満たす目的でやってみようと思います。

MDN の『純粋な JavaScript を使ったブロック崩しゲーム』の目次はこちら。

https://developer.mozilla.org/ja/docs/Games/Tutorials/2D_Breakout_game_pure_JavaScript

環境構築

Rust から wasm にコンパイルする手順は、これまた MDN が参考になります。ここでは簡単な説明に留めておきます。

https://developer.mozilla.org/ja/docs/WebAssembly/Rust_to_Wasm

執筆時点のバージョンはこちらです。

  • macOS 等
  • rustc 1.73.0 (cc66ad468 2023-10-03) (stable)
  • cargo 1.73.0 (9c4383fb5 2023-08-26)
  • wasm-pack 0.12.1
  • Python 3.* (確認用の Web サーバを立ち上げる用。PHP でも可)
  • Firefox 等の確認用 Web ブラウザ

Rust コンパイラはrustupでインストールしてます。rustupそのもののインストール方法は公式を見てください。
ここでは Rust コンパイラは stable バージョンとし、コンパイルターゲットにはwasm32-unknown-unknownが必要なので追加します。
*.wasm や*.d.td や*.js への書き出しにはwasm-packを使用します。

$ rustup install stable
$ rustup target add wasm32-unknown-unknown
$ cargo install wasm-pack

新しくプロジェクトを始めるにはcargoコマンドで下記のようにし、その後ディレクトリの中に入ります。

$ cargo new --lib wasm-mdn-2d-breakout-game-rust
$ cd wasm-mdn-2d-breakout-game-rust

Cargo.toml を編集して、wasm-bindgenクレートとweb-sysクレートを追加して置きます。
詳しい説明はRust から WebAssembly にコンパイルを確認してください。

Cargo.toml
[package]
name = "wasm-mdn-2d-breakout-game-rust"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2"

[dependencies.web-sys]
version = "0.3"
features = [
  "CanvasRenderingContext2d",
  "Document",
  "Element",
  "HtmlCanvasElement",
  "Window",
  "Location",
  "KeyboardEvent",
  "MouseEvent",
]

第 1 章 キャンバスを作ってその上に描画する

第 1 章の元の内容はこちらになります。

https://developer.mozilla.org/ja/docs/Games/Tutorials/2D_Breakout_game_pure_JavaScript/Create_the_Canvas_and_draw_on_it

本当に Rust から HTML の Canvas 要素に描画ができるのでしょうか?元の MDN の内容でも、いきなりゲームを作らずにまずは JavaScript から Canvas を操作しています。
src/lib.rsを下記のように編集します。

src/lib.rs
use wasm_bindgen::prelude::*;
use web_sys;

#[wasm_bindgen]
pub fn run() {
    let document = web_sys::window().unwrap().document().unwrap();

    // JavaScriptでは… const canvas = document.getElementById("myCanvas");
    let canvas = document.get_element_by_id("myCanvas").unwrap();
    let canvas = canvas
        .dyn_into::<web_sys::HtmlCanvasElement>()
        .map_err(|_| ())
        .unwrap();

    // JavaScriptでは… const ctx = canvas.getContext("2d");
    let ctx = canvas
        .get_context("2d")
        .unwrap()
        .unwrap()
        .dyn_into::<web_sys::CanvasRenderingContext2d>()
        .unwrap();

    // JavaScriptでは…
    // ctx.beginPath();
    // ctx.rect(20, 40, 50, 50);
    // ctx.fillStyle = "#FF0000";
    // ctx.fill();
    // ctx.closePath();
    ctx.begin_path();
    ctx.rect(20.0, 40.0, 50.0, 50.0);
    ctx.set_fill_style(&JsValue::from_str("#FF0000"));
    ctx.fill();
    ctx.close_path();
}

JavaScript に比べるとだいぶ長いですね。

JavaScript 側に公開する関数には#[wasm_bindgen]属性をつけて、関数宣言はpub fnとします。

set_fill_style()JsValue型を渡しているところが特徴的です。JsValueは wasm-bindgen のドキュメントによると、Rust 側ではなく JavaScript 側のメモリで保持される(?)ということらしいです。

https://rustwasm.github.io/wasm-bindgen/api/wasm_bindgen/struct.JsValue.html

試しにビルドしてみます。

$ wasm-pack build --target web

すると、pkgディレクトリが作られその中に *.js や *.wasm ファイルができます。今回は TypeScript を使用しないため *.ts は無視します。

これを HTML から呼び出してみます。プロジェクトディレクトリのルートにindex.htmlファイルを追加し、下記のようにします。

index.html
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>Gamedev Canvas Workshop</title>
    <style>
    * {
      padding: 0;
      margin: 0;
    }
    canvas {
      background: #eee;
      display: block;
      margin: 0 auto;
    }
    </style>
  </head>
  <body>
    <canvas id="myCanvas" width="480" height="320"></canvas>

    <script type="module">
      import init, { run } from "./pkg/wasm_mdn_2d_breakout_game_rust.js";
      init().then(() => {
        run();
      });
    </script>
  </body>
</html>

wasm の動作確認をするためには Web サーバが必要のようです。python には組み込みの Web サーバが内蔵されているので、これを使用します。

$ python -m http.server
Serving HTTP on :: port 8000 (http://[::]:8000/) ...

Web ブラウザからhttp://127.0.0.1:8000/にアクセスします。

現時点でのファイル構成はこのようになります。

├── Cargo.lock
├── Cargo.toml
├── index.html
├── pkg
│   ├── wasm_mdn_2d_breakout_game_rust.js
│   ├── wasm_mdn_2d_breakout_game_rust_bg.wasm
│   └── 他...
├── src
│   └── lib.rs
└── target
     └── ビルドの結果など...

他のコードも Rust で書いてみます。

src/lib.rs(に追加)
    ... (省略) ...

    // JavaScriptでは…
    // ctx.arc(240, 160, 20, 0, Math.PI * 2, false);
    // ctx.fillStyle = "green";
    ctx.begin_path();
    let _ = ctx.arc(240.0, 160.0, 20.0, 0.0, std::f64::consts::PI * 2.0);
    ctx.set_fill_style(&JsValue::from_str("green"));
    ctx.fill();
    ctx.close_path();

    // JavaScriptでは…
    // ctx.rect(160, 10, 100, 40);
    // ctx.strokeStyle = "rgba(0, 0, 255, 0.5)";
    // ctx.stroke();
    ctx.begin_path();
    ctx.rect(160.0, 10.0, 100.0, 30.0);
    ctx.set_stroke_style(&JsValue::from_str("rgba(0, 0, 255, 0.5)"));
    ctx.stroke();
    ctx.close_path();
}

Math.PIの代わりに Rust ではstd::f64::consts::PIを使います。rect()arc()に渡す引数はf64型です。

現時点ではソースコードを変更するたびにビルドし直します。

$ wasm-pack build --target web

HTML5 Canvas Context はweb_sys::CanvasRenderingContext2dのドキュメントをご参照ください。

https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.CanvasRenderingContext2d.html

第 2 章 ボールを動かす

第 2 章の元の内容はこちらになります。

https://developer.mozilla.org/ja/docs/Games/Tutorials/2D_Breakout_game_pure_JavaScript/Move_the_ball

第 2 章で早速悩みました。元の JavaScript ではsetInterval()が使用されていますが、Rust ではweb_sys::Windowset_interval_with_callback_and_timeout_and_arguments_0()を使います。
クロージャに渡す引数の数に応じて、末尾が arguments_0 だったり、arguments_1 だったりします。

https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.Window.html#method.set_interval_with_callback_and_timeout_and_arguments_0

一番のつまづきポイントは、set_interval_with_callback_and_timeout_and_arguments_0()にクロージャを渡しても実行されなかったり、最初の 1 回だけ実行されるという状況に悩まされたことです。

結論を書いてしまうと、Rust の関数スコープから抜けるとクロージャが開放されてしまい、コールバックが実行されない状態になっているということのようです。

対策としては 2 つあります。

1 つ目は、クロージャのforget()関数を呼び、意図的にメモリリークを起こして Rust から開放されない(drop されない)ようにするとのこと。こちらのページがとても参考になりました。

https://qiita.com/poccariswet/items/aefa48835320aead4155#3-forget

実際には wasm-bindgen のクロージャはforget()するとJsValue型にして JavaScript 側に持たせているようです。(Rust では drop されるので触れなくなりますが……)

https://github.com/rustwasm/wasm-bindgen/blob/a03d23bd16cb9b1706a71f20ba062e5532b369ec/src/closure.rs#L369-L378

2 つ目は、クロージャを構造体のメンバにしてしまうというやり方です。今回はこちらのやり方を真似してみようと思います。wasm-bindgen のガイドが参考になりました ↓

https://rustwasm.github.io/wasm-bindgen/reference/passing-rust-closures-to-js.html



ボール座標を保持するBallと、ゲームの状態を管理するGameという構造体を作り、Game構造体の中にメインループにあたる処理をgame_loop()という関数として書いていきます。

src/lib.rs
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use web_sys;

struct Ball {
    x: i32,
    y: i32,
    radius: i32, // ボールの半径
    dx: i32,     // ボールのx軸進行速度
    dy: i32,     // ボールのy軸進行速度
}

impl Ball {
    fn draw(&self, ctx: &web_sys::CanvasRenderingContext2d) {
        ctx.begin_path();
        let _ = ctx.arc(
            self.x as f64,
            self.y as f64,
            self.radius as f64,
            0.0,
            std::f64::consts::PI * 2.0,
        );
        ctx.set_fill_style(&JsValue::from_str("#0095DD"));
        ctx.fill();
        ctx.close_path();
    }
}

struct Game {
    canvas_context: web_sys::CanvasRenderingContext2d,
    canvas_width: i32,
    canvas_height: i32,
    ball: Ball,
    game_loop_closure: Option<Closure<dyn FnMut()>>, // ゲームループクローザ
    game_loop_interval_millis: u32,                  // ゲームループの間隔(ミリ秒)
    game_loop_interval_handle: Option<i32>,          // ゲームループのハンドル
}

impl Game {
    pub fn new() -> Self {
        let window = web_sys::window().unwrap();
        let document = window.document().unwrap();

        let canvas = document.get_element_by_id("myCanvas").unwrap();
        let canvas = canvas
            .dyn_into::<web_sys::HtmlCanvasElement>()
            .map_err(|_| ())
            .unwrap();

        let canvas_context = canvas
            .get_context("2d")
            .unwrap()
            .unwrap()
            .dyn_into::<web_sys::CanvasRenderingContext2d>()
            .unwrap();

        let canvas_width = canvas.width() as i32;
        let canvas_height = canvas.height() as i32;

        let ball = Ball {
            x: canvas_width / 2,
            y: canvas_height - 30,
            radius: 10,
            dx: 2,
            dy: -2,
        };

        Self {
            canvas_context,
            canvas_width,
            canvas_height,
            ball,
            game_loop_closure: None,
            game_loop_interval_millis: 10,
            game_loop_interval_handle: None,
        }
    }

    // ゲームループ
    fn game_loop(&mut self) {
        // ボールの描画
        self.ball.draw(&self.canvas_context);

        // ボールの移動
        self.ball.x = self.ball.x.saturating_add(self.ball.dx);
        self.ball.y = self.ball.y.saturating_add(self.ball.dy);
    }

    // メソッドではなく、関連関数なので Game::set_game_loop_and_start() として呼び出す
    // 引数には自分自身を Rc<RefCell<>> で包んだものを渡す
    pub fn set_game_loop_and_start(millis: u32, game: Rc<RefCell<Self>>) {
        let cloned_game = game.clone();
        let mut game_borrow = game.borrow_mut();

        game_borrow.set_game_loop(millis, move || cloned_game.borrow_mut().game_loop());
        game_borrow.start_game_loop();
    }

    fn set_game_loop<F: 'static>(&mut self, millis: u32, f: F)
    where
        F: FnMut(),
    {
        self.game_loop_closure = Some(Closure::new(f));
        self.game_loop_interval_millis = millis;
    }

    fn start_game_loop(&mut self) {
        // クロージャの参照を取り出す
        let closure = self.game_loop_closure.as_ref().unwrap();
        let millis = 0i32.saturating_add_unsigned(self.game_loop_interval_millis);
        let window = web_sys::window().unwrap();

        let handle = window.set_interval_with_callback_and_timeout_and_arguments_0(
            closure.as_ref().unchecked_ref(),
            millis,
        );

        self.game_loop_interval_handle = Some(handle.unwrap());
    }
}

#[wasm_bindgen]
pub fn run() {
    let game = Game::new();
    let game = Rc::new(RefCell::new(game));
    Game::set_game_loop_and_start(10, game.clone());
}

クロージャにGame自身をRc<RefCell<Game>>で包んだ状態にした変数をclone()して渡しています。クロージャの中にGameが保持されるような感じです。クロージャは毎回cloned_game.borrow_mut().game_loop()を実行します。

このやり方も先程のページを参考させていただきました。

https://qiita.com/poccariswet/items/aefa48835320aead4155#1-rcnewcellnew

そして wasm のクロージャwasm_bindgen::closure::Closure'staticなライフタイムを要求し、Rust のクロージャを JavaScript 側に保持する、ということのようです。(この理解であっているかな?)

詳しい内容はwasm_bindgen::closure::Closureのドキュメントを読んでください。

https://rustwasm.github.io/wasm-bindgen/api/wasm_bindgen/closure/struct.Closure.html

ボールの軌跡が画面に残り続けてしまうので、画面を描画の都度クリアするclear_rect()を使用します。

src/lib.rs(の一部)
     //
     fn game_loop(&mut self) {
+        // 画面を初期化
+        self.canvas_context.clear_rect(
+            0.0,
+            0.0,
+            self.canvas_width as f64,
+            self.canvas_height as f64,
+        );
+
         // ボールの描画
         self.ball.draw(&self.canvas_context);

         // ボールの移動
         self.ball.x = self.ball.x.saturating_add(self.ball.dx);
         self.ball.y = self.ball.y.saturating_add(self.ball.dy);
     }

今回の場合は i32 の足し算で桁があふれることはないと思いますが一応saturating_add()を使用しています。

第 3 章 ボールを壁で跳ね返させる

第 3 章の元の内容はこちらになります ↓

https://developer.mozilla.org/ja/docs/Games/Tutorials/2D_Breakout_game_pure_JavaScript/Bounce_off_the_walls

もしボールの位置の y の値が 0 未満だったら、符号反転させた値を設定することで y 軸方向の動きの向きを変えます。もしボールが上に向かって毎フレーム 2 ピクセルの速さで動いていたら、今度は「上」に向かって毎フレーム -2 ピクセルの速さで動く、つまり下に向かって毎フレーム 2 ピクセルの速さで動きます。

(中略)

y 座標がキャンバスの高さより高かったら(左上端から y の値を数えているため、上端は 0 で始まり下端はキャンバスの高さである 480 ピクセルとなることを思い出してください)、先程のように y 軸方向の動きを反転させます。

JavaScript
if (x + dx > canvas.width || x + dx < 0) {
  dx = -dx;
}

if (y + dy > canvas.height || y + dy < 0) {
  dy = -dy;
}

衝突検出のコードをgame_loop()内に追加していきます。
Ball構造体にあるボール座標xy、進行方向dxdy、そしてGame構造体にあるcanvas_widthcanvas_heightを使って壁との衝突時に反射するようにします。

src/lib.rs(の一部)
     fn game_loop(&mut self) {
         // 画面を初期化
         self.canvas_context.clear_rect(
             0.0,
             0.0,
             self.canvas_width as f64,
             self.canvas_height as f64,
         );

         // ボールの描画
         self.ball.draw(&self.canvas_context);

+        // ボールの移動先
+        let moved_ball_x = self.ball.x.saturating_add(self.ball.dx);
+        let moved_ball_y = self.ball.y.saturating_add(self.ball.dy);
+
+        // ボールと左右の壁の衝突
+        if moved_ball_x > self.canvas_width || moved_ball_x < 0 {
+            self.ball.dx = -self.ball.dx;
+        }
+
+        // ボールと上下の壁の衝突
+        if moved_ball_y > self.canvas_height || moved_ball_y < 0 {
+            self.ball.dy = -self.ball.dy;
+        }
+
         // ボールの移動
         self.ball.x = self.ball.x.saturating_add(self.ball.dx);
         self.ball.y = self.ball.y.saturating_add(self.ball.dy);
     }

『まだボールが壁に隠れる!』

たしかにボールが壁にめり込んでいるように見えます。ボールの座標がボールの中心を示しているため、ボールの中心座標が壁にこないと衝突したことになっていないようです。
元のコードでは、ボールの半径分の距離だけずらして判定しています。

ボールの中心と辺の距離がボールの半径とちょうど等しくなったときに動く向きを変えます。半径を辺の長さから引き、もう一方では足すことで衝突検出が正しく行われたような印象が出ます。思ったとおり、壁にぶつかった時点でボールが弾むようになります。

JavaScript
if (x + dx > canvas.width - ballRadius || x + dx < ballRadius) {
  dx = -dx;
}
if (y + dy > canvas.height - ballRadius || y + dy < ballRadius) {
  dy = -dy;
}

Rust で書くとこのような感じになります。Ball構造体にあるボールの半径radiusを使います。

src/lib.rs(の一部)
     fn game_loop(&mut self) {
         // 画面を初期化
         self.canvas_context.clear_rect(
             0.0,
             0.0,
             self.canvas_width as f64,
             self.canvas_height as f64,
         );

         // ボールの描画
         self.ball.draw(&self.canvas_context);

         // ボールの移動先
         let moved_ball_x = self.ball.x.saturating_add(self.ball.dx);
         let moved_ball_y = self.ball.y.saturating_add(self.ball.dy);

         // ボールと左右の壁の衝突
-        if moved_ball_x > self.canvas_width || moved_ball_x < 0 {
+        if moved_ball_x > self.canvas_width - self.ball.radius || moved_ball_x < self.ball.radius {
             self.ball.dx = -self.ball.dx;
         }

         // ボールと上下の壁の衝突
-        if moved_ball_y > self.canvas_height || moved_ball_y < 0 {
+        if moved_ball_y > self.canvas_height - self.ball.radius || moved_ball_y < self.ball.radius {
             self.ball.dy = -self.ball.dy;
         }

         // ボールの移動
         self.ball.x = self.ball.x.saturating_add(self.ball.dx);
         self.ball.y = self.ball.y.saturating_add(self.ball.dy);
     }

続きは中編へ

続きの中編はは第 4 章〜第 6 章になります。

https://zenn.dev/craneduck/articles/bf8899695fb17a

Discussion