🗂

RustとWebAssemblyによるゲーム開発を読み進めたらつまづきまくったので備忘録

に公開

findyのoreilly learningプラットフォームが90日無料で試せるヤツの抽選に当たってました
1ヶ月もそれに気付かず放置していたら、findyさんの方から「抽選当たってるでー気づいてー」とメッセージが来たので早速試しています(findyさんごめんなさいありがとう)
手始めに、諸事情で序盤以降が読めなくなってしまっていた
RustとWebAssemblyによるゲーム開発
という本を読み進めています

本の扱っている内容が発展途上の分野な為変化が早く、ツールの使い方などは特に詰まりやすいなと思ったので備忘録として残すことにしました

読み進めていく際詰まるところが出てくると思うので、記事の方もその都度更新していく方針で書いていきます

[!caution]
以下、2025/10時点での情報です

1章

rustupの代わりにnixを使っている場合の注意点

自分は fenix というoverlayを利用してrustのツールチェーンをインストールしています
普通にインストールしているとcargoひいてはrustcコマンドのターゲットはホストプラットフォームになっているかと思います
たとえば自分は、apple siliconのmacなので

❯ rustc -v -V
rustc 1.92.0-nightly (dc2c3564d 2025-09-29)
binary: rustc
commit-hash: dc2c3564d273cf8ccce32dc4f47eaa27063bceb9
commit-date: 2025-09-29
host: aarch64-apple-darwin # ←これがデフォルトのターゲット
release: 1.92.0-nightly
LLVM version: 21.1.2

となっています

wasm(具体的に、今回の場合はwasm32-unknown-unknown)をターゲットにビルドする際、
rustupを使っている場合は単純にrustupコマンド経由でwasm32-unknown-unknownをインストールすれば良いのですが、rustupを使わない場合は別の方法でターゲットプラットフォームをインストールする必要があります

自分の場合はdevshellを利用して環境構築をしているので、flake.nixを以下のように設定します

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
    flake-utils.url = "github:numtide/flake-utils";
    fenix.url = "github:nix-community/fenix";
    fenix.inputs.nixpkgs.follows = "nixpkgs";
  };

  outputs =
    {
      self,
      nixpkgs,
      flake-utils,
      fenix,
    }:
    flake-utils.lib.eachDefaultSystem (
      system:
      let
        pkgs = nixpkgs.legacyPackages.${system};
        fx = fenix.packages.${system};
        toolchain = fx.combine [
          fx.latest.rustc
          fx.latest.cargo
          fx.latest.rustfmt
          fx.latest.clippy
          fx.latest.rust-src
          fx.targets.wasm32-unknown-unknown.latest.rust-std
        ];
      in
      {
        devShells.default = pkgs.mkShell {
          buildInputs = [
            pkgs.nodejs_24
            pkgs.wasm-pack
            toolchain
          ];

          shellHook = ''
            echo -e "\033[1;32m\nRust + WASM dev environment loaded"
            echo -e "System: ${system}"
            echo -e "wasm-pack: $(which wasm-pack 2>/dev/null || echo 'not found')"
            echo -e "rustc:     $(which rustc 2>/dev/null || echo 'not found')"
            echo -e "cargo:     $(which cargo 2>/dev/null || echo 'not found')\033[0m\n"
          '';
        };
      }
    );
}

[!tip]
shellHookで実行している echo 内に\033[1 ... と見慣れぬコードがありますが、これはansi escape
sequence というものです
ansi escape sequence自体の説明は
https://stackoverflow.com/questions/4842424/list-of-ansi-color-escape-sequences
↑のリンクの回答がわかりやすいです

fx.combineに渡される配列内で示されたコンポーネントをひとまとまりのツールチェーンとして利用できる(という理解なんですけど合ってる?)
ので、あとはtoolchainをbuildInputsに指定してやればwasm32-unknown-unknown問題は解決します

shellでrustcのパスを確認してみましょう

❯ wh rustc
rustc is /nix/store/nixqji36ybcpjipvq5xh17i9zimmn58a-rust-mixed/bin/rustc
rustc is /Users/a/.nix-profile/bin/rustc

devshellのパスが先に来てたらOKです
稀にneovimのterminalバッファを使っているとグローバルな方が先に来てしまうのですが、
その状態でnpm run startすると

Error: wasm32-unknown-unknown target not found in sysroot: "/nix/store/7qqwr576zn72lib96j4kig162agh4cb9-rust-nightly-latest-2025-09-30"

Used rustc from the following path: "/Users/a/.nix-profile/bin/rustc"
It looks like Rustup is not being used. For non-Rustup setups, the wasm32-unknown-unknown target needs to be installed manually. See https://rustwasm.github.io/wasm-pack/book/prerequisites/non
-rustup-setups.html on how to do this.

というエラーが出るので、devshellをリロードしましょう
自分の場合はdirenvを使っているので一旦ディレクトリを抜けて戻ると解決します

テンプレートが古い

本の中でも言及されていますが、テンプレートがだいぶ古くなっています
npm init rust-webpackで生成されるCargo.tomlのエディションはなんと2018..
本の中でも軽くアップデートしていますが、それでもまだ古いのでエラーが出てしまいました
ということで、本腰を入れて更新作業をする必要があります

目標

ブラウザのコンソールで"Hello World!"と出力されているのを確認する

因みに、npm init rust-webpackで生成されるsrc/lib.rsは↓です

use wasm_bindgen::prelude::*;
use web_sys::console;

// When the `wee_alloc` feature is enabled, this uses `wee_alloc` as the global
// allocator.
//
// If you don't want to use `wee_alloc`, you can safely delete this.
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;

// This is like the `main` function, except for JavaScript.
#[wasm_bindgen(start)]
pub fn main_js() -> Result<(), JsValue,> {
	// This provides better error messages in debug mode.
	// It's disabled in release mode so it doesn't bloat up the file size.
	#[cfg(debug_assertions)]
	console_error_panic_hook::set_once();

	// Your code goes here!
	console::log_1(&JsValue::from_str("Hello world!",),);

	Ok((),)
}

Rust側

実はRust側の更新はそんなに大変ではありません
Cargo.tomlをいじるだけです
自分の場合は↓な感じにしました

# You must change these to your own details.
[package]
name = "rust-webpack-template"
version = "0.1.0"
authors = ["You <you@example.com>"]
categories = ["wasm"]
edition = "2024"
readme = "README.md"
description = "My super awesome Rust, WebAssembly, and Webpack project!"

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

[profile.release]
# This makes the compiled code faster and smaller, but it makes compiling slower,
# so it's only enabled in release mode.
lto = true

[features]
# If you uncomment this line, it will enable `wee_alloc`:
# default = ["wee_alloc"]

[dependencies]
# The `wasm-bindgen` crate provides the bare minimum functionality needed
# to interact with JavaScript.
wasm-bindgen = "*"
console_error_panic_hook = "*"

# `wee_alloc` is a tiny allocator for wasm that is only ~1K in code size
# compared to the default allocator's ~10K. However, it is slower than the default
# allocator, so it's not enabled by default.
wee_alloc = { version = "*", optional = true }

# The `web-sys` crate allows you to interact with the various browser APIs,
# like the DOM.
[dependencies.web-sys]
version = "*"
features = ["console"]

# The `console_error_panic_hook` crate provides better debugging of panics by
# logging them with `console.error`. This is great for development, but requires
# all the `std::fmt` and `std::panicking` infrastructure, so it's only enabled
# in debug mode.

# These crates are used for running unit tests.
[dev-dependencies]
futures = "*"
js-sys = "*"
wasm-bindgen-futures = "*"
wasm-bindgen-test = "*"

js(ツール)側

こちらは大変です..というか大変でした

が、結局やる事としては

  1. package.jsonの修正
  2. webpack.config.jsの修正

の二つです

package.jsonの修正

{
	"author": "You <you@example.com>",
	"name": "rust-webpack-template",
	"version": "0.1.0",
	"scripts": {
		"build": "webpack --mode production",
		"start": "webpack serve --open --mode development",
		"test": "cargo test && wasm-pack test --headless"
	},
	"devDependencies": {
		"@wasm-tool/wasm-pack-plugin": "*",
		"copy-webpack-plugin": "^5.0.3",
		"webpack": "^5.102.0",
		"webpack-cli": "^5.1.4",
		"webpack-dev-server": "*"
	}
}

正直nodeの使い方とかwebpack側の事情とか知ったこっちゃなので荒い修正なのは自覚しています
とりあえずこれで動くからええねんの精神で自分に言い訳していますが、指摘などあったら教えてください
涎垂らして喜びます

修正点としてはwebpackパッケージのアップデート(テンプレだと4系なので5系に)とそれに伴ったビルドコマンドの変更です

webpack.config.js

const path = require("path");
const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");

module.exports = {
  mode: "development",
  entry: "./js/index.js",
  // 必要
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "main.js",
    publicPath: "auto",
    clean: true,
  },
  experiments: {
    asyncWebAssembly: true,
    topLevelAwait: true,
  },
  module: {
    rules: [{ test: /\.wasm$/, type: "webassembly/async" }],
  },
  resolve: { extensions: [".js", ".wasm"] },
  // 必要
  devServer: {
    static: path.join(__dirname, "static"),
    hot: true,
    port: 8081,
  },
  devtool: "source-map",
  plugins: [
    new WasmPackPlugin({
      crateDirectory: path.resolve(__dirname),
      outDir: path.resolve(__dirname, "pkg"),
      extraArgs: "--target bundler",
      forceMode: "development",
    }),
    // 必要
    new CopyWebpackPlugin([
      { from: path.resolve(__dirname, "static"), to: path.resolve(__dirname, "dist") },
    ]),
  ],
};

こちらもと・動・え精神でごちゃごちゃを許容しています
指摘などあったら(ry

保証はできませんが、↑で必要とコメントされているブロックに関しては最低限必要な設定かと思われます
こいつらがないとビルドアーティファクトをちゃんと使ってくれなくなります

確認してみよう

ここまで修正すれば、ブラウザのコンソールにconsole::log_1へ渡した値が出力されているかと思います

されていない場合はwasmを読み込むjs側のエントリーポイントで、生成されたスクリプトをロードしているか確認しましょう

import "../pkg/index.js";

webpack.config.jsを再掲すると、自分は↓のようにjs/index.jsをエントリーポイントに指定しています

const path = require("path");
const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");

module.exports = {
  mode: "development",
  entry: "./js/index.js",
  // 必要
  output: {
    path: path.resolve(__dirname, "dist"),

...

個人的tips

rustで外部クレートを利用する際によくお世話になる docs.rs
ですが、実はtype based searchができます

例えば、web_sysクレートで

&JsValue -> HtmlCanvasElement

と検索すると↓のような結果が得られます

https://docs.rs/web-sys/latest/web_sys/struct.Element.html?search=%26JsValue -> HtmlCanvasElement

意外と知られてないこの機能ですが、🔍(Search)ボタンの右にある?(Help)ボタンをおすとガッツリ紹介されています
結構柔軟にクエリが書けて便利です

docs.rsを徘徊する時に記憶の端っこにあるとオイシイかもしれません

ランダムな数取得

ランダムな数を取得するために rand
クレートを利用していますが、そのままでwasm32-unknown-unknown向けにビルドするとエラーが出ます

error: The wasm32-unknown-unknown targets are not supported by default; you may need to enable the "wasm_js" configuration flag. Note that enabling the `wasm_js` feature flag alone is insufficient. For more information see: https://docs.rs/getrandom/0.3.3/#webassembly-support
   --> /Users/a/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/getrandom-0.3.3/src/backends.rs:168:9
    |
168 | /         compile_error!(concat!(
169 | |             "The wasm32-unknown-unknown targets are not supported by default; \
170 | |             you may need to enable the \"wasm_js\" configuration flag. Note \
171 | |             that enabling the `wasm_js` feature flag alone is insufficient. \
172 | |             For more information see: \
173 | |             https://docs.rs/getrandom/", env!("CARGO_PKG_VERSION"), "/#webassembly-support"
174 | |         ));
    | |__________^

error[E0425]: cannot find function `fill_inner` in module `backends`
  --> /Users/a/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/getrandom-0.3.3/src/lib.rs:99:19
   |
99 |         backends::fill_inner(dest)?;
   |                   ^^^^^^^^^^ not found in `backends`

error[E0425]: cannot find function `inner_u32` in module `backends`
   --> /Users/a/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/getrandom-0.3.3/src/lib.rs:128:15
    |
128 |     backends::inner_u32()
    |               ^^^^^^^^^ not found in `backends`
    |
help: consider importing this function
    |
 33 + use crate::util::inner_u32;
    |
help: if you import `inner_u32`, refer to it directly
    |
128 -     backends::inner_u32()
128 +     inner_u32()
    |

error[E0425]: cannot find function `inner_u64` in module `backends`
   --> /Users/a/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/getrandom-0.3.3/src/lib.rs:142:15
    |
142 |     backends::inner_u64()
    |               ^^^^^^^^^ not found in `backends`
    |
help: consider importing this function
    |
 33 + use crate::util::inner_u64;
    |
help: if you import `inner_u64`, refer to it directly
    |
142 -     backends::inner_u64()
142 +     inner_u64()
    |

これはrandが依存するgetrandomクレート由来のエラーですね
本書ではCargo.tomlでgetrandomのjsフィーチャーを有効にすればええねんで、って書いてますがこれは古い情報です

じゃあどうすれば良いの?っていうとエラーメッセージ内のリンク
が正しく求めていたものなんですが、見落としやすい箇所があるのでめも

getrandomクレートの設定

要は以下の2つを両方とも設定すると解消します

  1. "wasm_js"フィーチャーを設定する
  2. wasm32-unknown-unknownターゲットにビルドする際はgetrandom_backendフラグを"wasm_js"にする

1番はそのまま

getrandom = { version = "*", features = ["wasm_js"] }

としてやればおk

個人的には結構びっくりなんですが、利用クレートが依存してるクレートの設定って自身のCargo.tomlから弄れるんですね
まぁそれが出来ないと困るケースがあると言うのは想像に難くないというか正しく↑のケースが該当しますよね
多分一番驚いているのは、その設定の仕方だと思います
randクレートが依存しているgetrandomの設定は~~って言う書き方なら分かるんですが、なんの縛りも無しにグローバル?にボンっと置いちゃって大丈夫なんでしょうか
菱形依存してる時とかどうするんだ..?

余談終わり

2番は、コマンドを走らせる際に逐一RUST_FLAG環境変数を設定する方法と、.cargo/config.tomlに設定する方法があります
正直後者を差し置いて前者を選ぶ利点が今回のケースでは思い当たらないので後者のみ書き残します

Cargo.tomlが存在するディレクトリから見て.cargo/config.tomlにあたるファイルを作成・編集すれば良いわけです

内容は

# It's recommended to set the flag on a per-target basis:
[target.wasm32-unknown-unknown]
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']

wasm32-unknown-unknownをターゲットにビルドする際のみ指定のフラグを有効にしますと言う設定です

[!tip]
neovimを使っている方向けの小ネタですが、例えば.cargo/ディレクトリが存在しない状態で

nvim .cargo/config.toml

とすると、保存の際にエラーが出てしまいます
これは、.cargo/ディレクトリが存在しないにも関わらず、.cargo/配下にファイルを保存しようとしている為です
もうちょっと柔軟に対応してくれ この場合:write ++pと、
writeコマンドに++pオプションを指定してやれば、必要に応じてディレクトリの作成を行なってくれます

2章

spawn_localが引数にasyncブロックを受け付けない

これは進め方によって出ないこともあるかと思います

自分の場合は、本に沿って実装を進め、wasm_bindgen_futures::spawn_localの引数にasyncブロックを渡すと

• the trait bound `{async block@src/lib.rs:292:36: 292:46}: futures::future::Future` is not satisfied
the following other types implement trait `futures::future::Future`:
&'a mut F
futures::future::and_then::AndThen<A, B, F>
futures::future::catch_unwind::CatchUnwind<F>
futures::future::either::Either<A, B>
futures::future::empty::Empty<T, E>
futures::future::flatten::Flatten<A>
futures::future::from_err::FromErr<A, E>
futures::future::fuse::Fuse<A>
and 44 others [E0277]
lib.rs:291:1: required by a bound introduced by this call
legacy_shared.rs:100:7: required by a bound in `wasm_bindgen_futures::spawn_local`

と言うエラーが出てしまいました
先に対症療法を書くと、このようなエラーが出た場合は

[dependencies.wasm-bindgen-futures]
version = "*"

新しいバージョンのwasm-bindgen-futures(おそらく0.4系以上)を使っていることを確認した上で
Cargo.lockを削除すれば治るはず

原因(多分)

これだけだと自分が気持ち悪いので、後付けですがエラーが出た原因を推測してみます

まずエラーが出ている時のCargo.lockには以下のような記述があります

[[package]]
name = "wasm-bindgen-futures"
version = "0.3.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83420b37346c311b9ed822af41ec2e82839bfe99867ec6c54e2da43b7538771c"
dependencies = [
	"cfg-if 0.1.10",
	"futures 0.1.31",
	"js-sys",
	"wasm-bindgen",
	"web-sys",
]

wasm-bindgen-futuresのバージョンが0.3.27になっています
この状態でhover docしてwasm_bindgen_futures::spawn_localのシグネチャを確認すると

pub fn spawn_local<F>(future: F)
where
    F: Future<Item = (), Error = ()> + 'static,

となっています
一方、asyncブロックの型は

type of async_block

なんか見難いですが

impl Future<Output = ()>

になっています
asyncブロックは言語機能として提供されているわけなのでcore::future::Futureトレイトを実装します
一方

wasm_bindgen_futuresのバージョン0.3.27時点ではspawn_localの引数は以下の通り

https://docs.rs/wasm-bindgen-futures/0.3.27/wasm_bindgen_futures/fn.spawn_local.html

バージョン0.1.28のfuturesトレイトが提供するfutures::future::Futureトレイトを実装した型、になっています
ここがミスマッチしていた為エラーになっていたわけですね

憶測ですが、wasm_bindgen_futuresのバージョン0.3.27がリリースされて以降に非同期Rustの言語使用に変更が入り、
その影響を受けてwasm_bindgen_futuresクレートはcore::future::Futureに準拠するようになった、って事があったんだと思います(適当)

[!tip]
またまた余談です
docs.rsでは過去バージョンのドキュメントを読む事ができます
how to switch crate version on docs.rs

set_onloadに渡すクロージャーの引数

つまづいた事では無いのですが、気になったのでやってみた!と言う節です

本ではset_onloadとset_onerrorに対して別々のコールバックを用意しています
oneshotチャンネルをMutexで共有するという非同期Rustの導入としてぶち込んだものと思われますが、この2つのコールバックは1つに纏められます

と言うのもMDNを見ていて気になったのですが、
onloadonerrorにあたるコールバックの引数(JS側の話です)がどちらもEventオブジェクトになっています
UI由来のエラーの場合はErrorEventやでーって書いてありますが、今回はそのケースでは無いので実は2つのコールバックのシグネチャは同じことがわかります

シグネチャが同じなら纏められるのでは?と思って試してみました↓

			let (tx, rx,) = futures::channel::oneshot::channel::<JRslt<(),>,>();

			let callback = Closure::once(|e: Event| {
				console::log_1(&e,);
				if e.type_() == "load" {
					tx.send(Ok((),),)
						.expect("failed to send success message from callback",)
				} else {
					tx.send(Err(e.into(),),)
						.expect("failed to send error message from callback",)
				};
			},);

			let image = HtmlImageElement::new()?;

			// set callback when loading asset finished
			image.set_onload(Some(callback.as_ref().unchecked_ref(),),);
			image.set_onerror(Some(callback.as_ref().unchecked_ref(),),);

			image.set_src("Idle (1).png",);
			match rx.await {
				Ok(sent_msg,) => match sent_msg {
					Ok(_,) => {
						console_log!("load success");
						ctx.draw_image_with_html_image_element(
							&image, 0.0, 0.0,
						)?
					},
					Err(e,) => {
						console_log!("error happen while loading asset");
						console::error_1(&e,)
					},
				},
				Err(e,) => {
					let e = JsValue::from_str(&e.to_string(),);
					console_log!(
						"error happen while sending message from callback"
					);
					console::error_1(&e,);
				},
			};

Event型を利用する為にはweb_sysのEventフィーチャーを有効にする必要があります

[dependencies.web-sys]
version = "*"
features = [
	"console",
	"Window",
	"Document",
	"Element",
	"HtmlCanvasElement",
	"CanvasRenderingContext2d",
	"HtmlImageElement",
	"Event", # ←コレ!!
]

コールバックの最初でe引数をコンソールに表示しています

web_sys::console::log_1が受け付ける引数の型は&JsValueですが、&Eventを渡しています
コレがなぜ許されるのかというと、Event型がDeref<Target = JsValue>を実装しているからです

https://docs.rs/web-sys/latest/web_sys/struct.Event.html#deref-methods-JsValue

丁度&String&strと見なされるのと同じです
String型はDeref<Target = str>を実装している為、String型の参照はstr型の参照、つまり&strとしても解釈することが出来ます
そしてEvent型はDeref<Target = JsValue>を実装している為、その参照を&JsValueとして渡すことが出来ます

ifの部分でe.type_() == "load"とあります

if e.type_() == "load" {
	tx.send(Ok((),),)
		.expect("failed to send success message from callback",)
} else {
	tx.send(Err(e.into(),),)
		.expect("failed to send error message from callback",)
};

コレはjsにおける

e.type === "load"

と同じです
とまぁ偉そうに解説面をしてきましたが、
試しにコンソールにeを出力するだけのコードを実行したところ

[Log] Event (main.js, line 3999)
bubbles: false
cancelBubble: false
cancelable: false
composed: false
currentTarget: null
defaultPrevented: false
eventPhase: 0
isTrusted: true
returnValue: true
srcElement: <img>
target: <img>
timeStamp: 793
type: "load"

と言う出力を得たのでそれを元に書いたと言うだけ

into_serdeの循環参照

2章の後半ではserdeを使ってjsのオブジェクト(の表現)からjsonを解析するくだりがあります
本では、wasm-bindgenのフィーチャーを追加し

[dependencies.wasm-bindgen]
version = "*"
features = ["serde-serialize"]

into_serdeメソッドを使ってdeしています
ですが訳註の3にもある通り、現在into_serdeを循環参照を引き起こす為非推奨となっています
訳註には代替としてgloo-utilsクレートを使う方法が提示されていますが、rust-analyzerのエラー出力を見た感じgloo-utilsを使うよりserde_wasm_bindgenクレートを使った方が現時点ではオススメな方法なのかなと思います

serde_wasm_bindgenを使ってデシリアライズ

まず、Cargo.tomlのwasm-bindgenの設定はそのままにしておきましょう
dependenciesテーブルにserde-wasm-bindgenを追加すれば導入は完了です

多分ここまでで気になってる人も多いかと思うのですが、自分が個人的に開発する際は基本的に全て最新の物を使うようにしています
いつも脳死でバージョンにワイルドカード指定しているので正直コレ以外の表記をあんまり知らない

serde-wasm-bindgen = "*"

[dependencies.wasm-bindgen]
version = "*"

デシリアライズする際はinto_serdeの代わりにserde_wasm_bindgen::from_valueを使います↓

// 例
async fn fetch_sprite_sheet_mapper(json_path: &str,) -> JRslt<Sheet,> {
	let json = fetch_json(json_path,).await?;
	let sheet: Sheet = serde_wasm_bindgen::from_value(json,).expect(
		"failed to parse js object representation into rust struct: `Sheet`",
	);
	Ok(sheet,)
}

因みに↑のコード例で出てくるJRsltという型名、↓のように定義しています

type JRslt<T,> = Result<T, JsValue,>;

コレ要る?って言われると微妙だけどこう言う細かいのが大事って信じてる うん

Discussion