(前編)MDNブロック崩しをJSからRustに移植してWebAssemblyに入門
趣味の 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 を使ったブロック崩しゲーム』の目次はこちら。
環境構築
Rust から wasm にコンパイルする手順は、これまた MDN が参考になります。ここでは簡単な説明に留めておきます。
執筆時点のバージョンはこちらです。
- 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 にコンパイルを確認してください。
[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 章の元の内容はこちらになります。
本当に Rust から HTML の Canvas 要素に描画ができるのでしょうか?元の MDN の内容でも、いきなりゲームを作らずにまずは JavaScript から Canvas を操作しています。
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 側のメモリで保持される(?)ということらしいです。
試しにビルドしてみます。
$ wasm-pack build --target web
すると、pkg
ディレクトリが作られその中に *.js や *.wasm ファイルができます。今回は TypeScript を使用しないため *.ts は無視します。
これを 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 で書いてみます。
... (省略) ...
// 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
のドキュメントをご参照ください。
第 2 章 ボールを動かす
第 2 章の元の内容はこちらになります。
第 2 章で早速悩みました。元の JavaScript ではsetInterval()
が使用されていますが、Rust ではweb_sys::Window
のset_interval_with_callback_and_timeout_and_arguments_0()
を使います。
クロージャに渡す引数の数に応じて、末尾が arguments_0 だったり、arguments_1 だったりします。
一番のつまづきポイントは、set_interval_with_callback_and_timeout_and_arguments_0()
にクロージャを渡しても実行されなかったり、最初の 1 回だけ実行されるという状況に悩まされたことです。
結論を書いてしまうと、Rust の関数スコープから抜けるとクロージャが開放されてしまい、コールバックが実行されない状態になっているということのようです。
対策としては 2 つあります。
1 つ目は、クロージャのforget()
関数を呼び、意図的にメモリリークを起こして Rust から開放されない(drop されない)ようにするとのこと。こちらのページがとても参考になりました。
実際には wasm-bindgen のクロージャはforget()
するとJsValue
型にして JavaScript 側に持たせているようです。(Rust では drop されるので触れなくなりますが……)
2 つ目は、クロージャを構造体のメンバにしてしまうというやり方です。今回はこちらのやり方を真似してみようと思います。wasm-bindgen のガイドが参考になりました ↓
ボール座標を保持するBall
と、ゲームの状態を管理するGame
という構造体を作り、Game
構造体の中にメインループにあたる処理をgame_loop()
という関数として書いていきます。
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()
を実行します。
このやり方も先程のページを参考させていただきました。
そして wasm のクロージャwasm_bindgen::closure::Closure
は'static
なライフタイムを要求し、Rust のクロージャを JavaScript 側に保持する、ということのようです。(この理解であっているかな?)
詳しい内容はwasm_bindgen::closure::Closure
のドキュメントを読んでください。
ボールの軌跡が画面に残り続けてしまうので、画面を描画の都度クリアするclear_rect()
を使用します。
//
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 章の元の内容はこちらになります ↓
もしボールの位置の y の値が 0 未満だったら、符号反転させた値を設定することで y 軸方向の動きの向きを変えます。もしボールが上に向かって毎フレーム 2 ピクセルの速さで動いていたら、今度は「上」に向かって毎フレーム -2 ピクセルの速さで動く、つまり下に向かって毎フレーム 2 ピクセルの速さで動きます。
(中略)
y 座標がキャンバスの高さより高かったら(左上端から y の値を数えているため、上端は 0 で始まり下端はキャンバスの高さである 480 ピクセルとなることを思い出してください)、先程のように y 軸方向の動きを反転させます。
JavaScriptif (x + dx > canvas.width || x + dx < 0) { dx = -dx; } if (y + dy > canvas.height || y + dy < 0) { dy = -dy; }
衝突検出のコードをgame_loop()
内に追加していきます。
Ball
構造体にあるボール座標x
とy
、進行方向dx
とdy
、そしてGame
構造体にあるcanvas_width
とcanvas_height
を使って壁との衝突時に反射するようにします。
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);
}
『まだボールが壁に隠れる!』
たしかにボールが壁にめり込んでいるように見えます。ボールの座標がボールの中心を示しているため、ボールの中心座標が壁にこないと衝突したことになっていないようです。
元のコードでは、ボールの半径分の距離だけずらして判定しています。
ボールの中心と辺の距離がボールの半径とちょうど等しくなったときに動く向きを変えます。半径を辺の長さから引き、もう一方では足すことで衝突検出が正しく行われたような印象が出ます。思ったとおり、壁にぶつかった時点でボールが弾むようになります。
JavaScriptif (x + dx > canvas.width - ballRadius || x + dx < ballRadius) { dx = -dx; } if (y + dy > canvas.height - ballRadius || y + dy < ballRadius) { dy = -dy; }
Rust で書くとこのような感じになります。Ball
構造体にあるボールの半径radius
を使います。
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 章になります。
Discussion