WebAssembly (WASM) 入門 — ブラウザで重い処理を実行する

WebAssembly(WASM)を使うと、ブラウザ上で C++・Rust などのネイティブコードをほぼネイティブ速度で実行できます。この記事では WASM の仕組みから Rust での実装・JavaScript への組み込み・Godot/Unity の Web 出力との関係まで解説します。

WebAssembly とは

WebAssembly(WASM)は W3C が標準化したバイナリ形式の命令セットです。ブラウザの JavaScript エンジン上で動作し、C・C++・Rust など多くの言語からコンパイルできます。

JavaScript と WASM の比較

項目JavaScriptWebAssembly
形式テキスト(動的型付け)バイナリ(静的型付け)
実行速度JIT コンパイルで高速ネイティブに近い速度
起動速度速いやや遅い(解析コスト)
メモリ管理GC(自動)線形メモリ(手動 or 言語依存)
DOM 操作直接可能JS 経由が必要
対応ブラウザ全主要ブラウザ全主要ブラウザ(2017年〜)
ユースケース汎用計算集中型処理

WASM が得意な処理

  • 画像・動画処理: フィルタ・エンコード・デコード
  • 音声処理: リアルタイムエフェクト・FFT
  • 暗号処理: ハッシュ計算・暗号化
  • 物理シミュレーション: 流体・剛体
  • ゲームエンジン: Godot・Unity の Web 出力
  • 機械学習推論: ONNX Runtime WebAssembly

WASM の仕組み

ソースコード (Rust / C++ / C)
    ↓ コンパイル(wasm-pack / emcc)
.wasm ファイル(バイナリ)+ JS グルーコード
    ↓ ブラウザが読み込み
WebAssembly モジュール(メモリ・関数テーブル)
JavaScript から関数を呼び出す

WASM モジュールは線形メモリ(1 次元のバイト配列)を持ち、JS と WASM の間でデータをやり取りする際はこのメモリを介します。

Rust → WASM のセットアップ(wasm-pack)

必要なツール

# Rust のインストール
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# WASM ターゲットの追加
rustup target add wasm32-unknown-unknown

# wasm-pack のインストール
cargo install wasm-pack

# バージョン確認
wasm-pack --version

プロジェクトの作成

# wasm-pack テンプレートで新規プロジェクト
cargo new --lib wasm-hello
cd wasm-hello

Cargo.toml を編集します。

[package]
name = "wasm-hello"
version = "0.1.0"
edition = "2021"

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

[dependencies]
wasm-bindgen = "0.2"

[profile.release]
opt-level = "s"   # サイズ最適化

Rust コードを書く

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

// JavaScript から呼び出せる関数
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello, {}! From WebAssembly.", name)
}

// 重い計算処理の例: フィボナッチ数列
#[wasm_bindgen]
pub fn fibonacci(n: u32) -> u64 {
    match n {
        0 => 0,
        1 => 1,
        _ => {
            let mut a: u64 = 0;
            let mut b: u64 = 1;
            for _ in 2..=n {
                let tmp = a + b;
                a = b;
                b = tmp;
            }
            b
        }
    }
}

// 画像処理の例: グレースケール変換
#[wasm_bindgen]
pub fn grayscale(pixels: &mut [u8]) {
    // RGBA 形式のピクセルデータをグレースケールに変換
    for chunk in pixels.chunks_mut(4) {
        let r = chunk[0] as f32;
        let g = chunk[1] as f32;
        let b = chunk[2] as f32;
        let gray = (0.299 * r + 0.587 * g + 0.114 * b) as u8;
        chunk[0] = gray;
        chunk[1] = gray;
        chunk[2] = gray;
        // chunk[3] はアルファ値なので変更しない
    }
}

ビルド

# npm パッケージとしてビルド
wasm-pack build --target web

# または bundler 向け(webpack / Vite)
wasm-pack build --target bundler

# 出力先: pkg/
# ├── wasm_hello.js       ← JS グルーコード
# ├── wasm_hello_bg.wasm  ← WASM バイナリ
# ├── wasm_hello.d.ts     ← TypeScript 型定義
# └── package.json

JavaScript / TypeScript から使う

バニラ JS(ブラウザ直接)

<!-- index.html -->
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body>
<canvas id="canvas" width="400" height="400"></canvas>
<script type="module">
  import init, { greet, fibonacci, grayscale } from "./pkg/wasm_hello.js";

  async function main() {
    // WASM モジュールを初期化
    await init();

    // 文字列を返す関数
    console.log(greet("World"));
    // → "Hello, World! From WebAssembly."

    // 重い計算
    console.log(fibonacci(50));
    // → 12586269025

    // 画像処理(Canvas API と組み合わせ)
    const canvas = document.getElementById("canvas");
    const ctx = canvas.getContext("2d");
    const img = new Image();
    img.onload = () => {
      ctx.drawImage(img, 0, 0);
      const imageData = ctx.getImageData(0, 0, 400, 400);

      // WASM でグレースケール変換
      grayscale(imageData.data);

      ctx.putImageData(imageData, 0, 0);
    };
    img.src = "photo.jpg";
  }

  main();
</script>
</body>
</html>

Vite + TypeScript での組み込み

npm create vite@latest my-wasm-app -- --template vanilla-ts
cd my-wasm-app
npm install
// src/main.ts
import init, { fibonacci } from "../pkg/wasm_hello.js";

async function run() {
  await init();

  const start = performance.now();
  const result = fibonacci(45);
  const end = performance.now();

  console.log(`fibonacci(45) = ${result}`);
  console.log(`実行時間: ${(end - start).toFixed(2)}ms`);
}

run();

vite.config.ts で WASM を有効化します。

import { defineConfig } from "vite"

export default defineConfig({
  // Vite 5 以降は WASM がデフォルトでサポートされています
  optimizeDeps: {
    exclude: ["wasm-hello"]
  }
})

Web Workers と組み合わせて UI をブロックしない

重い WASM 処理はメインスレッドをブロックします。Web Workers で別スレッドで実行するのがベストプラクティスです。

// worker.ts
import init, { fibonacci } from "../pkg/wasm_hello.js";

let initialized = false;

self.onmessage = async (e) => {
  if (!initialized) {
    await init();
    initialized = true;
  }
  const { n } = e.data;
  const result = fibonacci(n);
  self.postMessage({ result });
};
// main.ts
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
  type: "module"
});

worker.postMessage({ n: 50 });
worker.onmessage = (e) => {
  console.log("Result:", e.data.result);
};

Godot 4 の Web エクスポートと WASM

Godot 4 の Web エクスポートは GDScript・C#・GDExtension(C++)のコードをEmscripten を使って WASM にコンパイルします。

エクスポート手順

  1. エクスポートテンプレートのダウンロード

    • エディタエクスポートテンプレートの管理ダウンロード
  2. Web エクスポートの設定

    • プロジェクトエクスポート追加Web
  3. 必要な設定

Variant: Standard(通常)/ Thread(マルチスレッド対応)
Extensions Support: GDExtension の C++ コードがある場合に有効化
  1. エクスポート実行
godot --export-release "Web" build/index.html

Godot Web エクスポートの WASM 構造

build/
├── index.html          ← エントリーポイント
├── index.js            ← Emscripten グルーコード
├── index.wasm          ← エンジン本体(数十 MB)
├── index.pck           ← ゲームデータ(Packed file)
└── index.audio.worklet.js

SharedArrayBuffer の注意点

Godot の Thread(マルチスレッド)バリアントを使う場合、SharedArrayBuffer が必要です。これは COOP/COEP HTTP ヘッダーを設定しないと動作しません。

# サーバーの設定に必要なヘッダー
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

GitHub Pages では COOP/COEP ヘッダーが設定できないため、Thread バリアントは GitHub Pages での公開に向きません。

Godot 4.3 以降の対策: シングルスレッドエクスポートを選択すると SharedArrayBuffer 不要になります。また Progressive Web App > Enable オプションを有効にすると、Service Worker でヘッダーをシミュレートして GitHub Pages でも動作させられます。

Unity の Web ビルドと WASM

Unity の WebGL ビルドも同様に Emscripten で WASM を生成します。

ビルド設定:
File → Build Settings → WebGL → Switch Platform → Build

出力:
Build/
├── index.html
├── Build/
│   ├── game.loader.js
│   ├── game.data            ← ゲームデータ
│   ├── game.framework.js    ← Unity ランタイム
│   └── game.wasm            ← Unity エンジン WASM

Unity WebGL の制限事項

  • マルチスレッド: Unity 6 から WebAssembly スレッドの実験的サポート(COOP/COEP 必要)
  • ファイルシステム: IndexedDB を使った仮想ファイルシステム
  • ネイティブプラグイン: .dll は使えない(JavaScript プラグインに変換必要)

ハマりやすいポイント

WASM のバイナリサイズが大きくなる

Rust の WASM バイナリはデフォルトで数 MB になることがあります。wasm-opt で最適化します。

# wasm-pack は自動で wasm-opt を実行するが、手動で行う場合
cargo install wasm-opt
wasm-opt pkg/wasm_hello_bg.wasm -Os -o pkg/wasm_hello_bg.wasm

# Cargo.toml でサイズ最適化
[profile.release]
opt-level = "s"   # サイズ優先
lto = true        # Link Time Optimization

JS ↔ WASM 間のデータ受け渡しコストに注意

WASM の線形メモリと JS のヒープは別物です。文字列や配列を渡すたびにコピーが発生します。頻繁に呼び出す場合はバッチ処理にまとめます。

// NG: 1 ピクセルずつ処理(JS → WASM のコール多数)
#[wasm_bindgen]
pub fn process_pixel(r: u8, g: u8, b: u8) -> u8 { ... }

// OK: バッファ全体を一度に渡す
#[wasm_bindgen]
pub fn process_image(pixels: &mut [u8]) { ... }

WASM は同期的にロードできない

init() は非同期(Promise)を返します。await を忘れると関数呼び出しが失敗します。

// NG: await なし
import init, { fibonacci } from "./pkg/wasm_hello.js";
init();
fibonacci(10); // エラー: モジュールが初期化されていない

// OK
await init();
fibonacci(10);

Godot Web エクスポートの MIME タイプ設定

.wasm ファイルを配信するサーバーは Content-Type: application/wasm を返す必要があります。設定がないとブラウザがエラーを出します。

# nginx の設定例
types {
    application/wasm wasm;
}

まとめ

  • WebAssembly は C++・Rust などをブラウザでネイティブ速度で実行する標準技術
  • Rust + wasm-pack で比較的簡単に WASM モジュールを作成し JS から呼び出せる
  • 重い処理は Web Workers と組み合わせて UI をブロックしないよう設計する
  • Godot 4・Unity の Web エクスポートは Emscripten 経由で WASM を生成している
  • SharedArrayBuffer(マルチスレッド)使用時は COOP/COEP ヘッダー設定が必須