[JavaScript] WebAssembly(WASM)とEmscriptenの全体像 ― C/C++をブラウザで動かすまでの道のり

[JavaScript] WebAssembly(WASM)とEmscriptenの全体像 ― C/C++をブラウザで動かすまでの道のり

この記事の目的

WebAssembly(WASM)とEmscriptenについて、自分用のリファレンスとして体系的にまとめる。

laz-perfのカスタムWASMビルド(250KB)を実際に作った経験がベースになっているので、「概念は知ってるけど全体像が曖昧」という状態から、次に何かビルドする時に迷わないレベルを目指す。


WebAssembly(WASM)とは何か

一言で言うと

ブラウザで動くバイナリ形式の実行フォーマット。

JavaScriptの代替ではなく、JavaScriptでは難しい処理(重い計算、既存のC/C++資産の流用)をブラウザ上で実行するための仕組み。

なぜ必要なのか

JavaScriptは高速だが、以下のようなケースでは限界がある。

  • 数学的に複雑なアルゴリズム — 圧縮、暗号化、画像処理、3D演算
  • 既存のC/C++ライブラリ — 何十年もかけて最適化されたコードをJSで書き直すのは非現実的
  • パフォーマンスが予測しやすい — JITコンパイラの気まぐれに左右されにくい

重要なのは、WASMの価値は「速度」だけではないということ。「ブラウザで動かせなかったものを動かせる」 のが本質。LAZ圧縮をJSでゼロから実装するより、C++の実績あるライブラリをそのままWASMにする方が正確で、速くて、保守もしやすい。

.wasmファイルの正体

.wasmはバイナリ形式のファイル。人間が読むものではなく、ブラウザのWASMランタイムが直接実行する。テキスト表現(.wat)もあるが、通常は意識しなくていい。

C/C++ソースコード → コンパイラ → .wasm(バイナリ) → ブラウザが実行

ネイティブビルドで.exe.soが出てくるのと同じ感覚で、ターゲットが「ブラウザのWASMランタイム」になっただけ。


Emscriptenとは何か

位置づけ

C/C++コードをWASMにコンパイルするためのツールチェーン(クロスコンパイラ)。

GCCやClangが「x86やARM向けのネイティブバイナリ」を出力するのに対して、Emscriptenは「WASM + JavaScriptグルーコード」を出力する。内部的にはClang/LLVMを使っている。

GCC   : C/C++ → x86バイナリ(.exe, .out)
Clang : C/C++ → x86/ARMバイナリ
emcc  : C/C++ → .wasm + .js(グルーコード)

emccコマンド

Emscriptenのコンパイラコマンドがemcc(C++の場合はem++)。使い方はgcc/clangとほぼ同じ。

# 普通のCビルド
gcc main.c -o main

# WASM向けビルド
emcc main.c -o main.js

出力ファイル名の拡張子で挙動が変わる。

出力指定生成物
-o out.jsout.js(グルーコード)+ out.wasm
-o out.htmlout.html + out.js + out.wasm(テスト用HTMLも生成)
-o out.wasmout.wasmのみ(グルーコードなし、上級者向け)

基本は-o out.jsを使う。

グルーコードとは

Emscriptenが自動生成するJavaScriptファイル。C/C++の世界とブラウザの世界を橋渡しする接着剤

C/C++のプログラムは以下のようなことを前提にしている。

  • ファイルシステムがある(fopen, fwrite
  • メモリを直接管理する(malloc, free
  • 標準出力がある(printf

ブラウザにはこれらが存在しないので、グルーコードが擬似的に再現する。

  • 仮想ファイルシステム(MEMFS)
  • WASMのリニアメモリ上でのmalloc/free
  • console.logへの出力リダイレクト

だからC/C++のコードを「ブラウザを意識せずにそのまま」コンパイルできる。


Emscriptenの環境構築(emsdk)

インストール

git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh

WSL(Windows Subsystem for Linux)上でも問題なく動く。実際にlaz-perfビルドではWSL上のemsdk 3.1.64を使った。

確認

emcc --version
# emcc (Emscripten gcc/clang-like replacement ...) x.x.x

emccem++が使えればOK。


ビルドの基本パターン

パターン1: 単一ファイルのCコード

最もシンプルなケース。

// add.c
#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE
int add(int a, int b) {
    return a + b;
}
emcc add.c -o add.js \
  -s EXPORTED_FUNCTIONS='["_add", "_malloc", "_free"]' \
  -s EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]'

ブラウザ側:

const Module = await createModule();
const add = Module.cwrap('add', 'number', ['number', 'number']);
console.log(add(2, 3)); // 5

ポイント

  • EMSCRIPTEN_KEEPALIVE — コンパイラの最適化で関数が消されるのを防ぐ
  • EXPORTED_FUNCTIONS — 外部に公開する関数を指定。_プレフィックスが必要
  • cwrap — C関数をJSから呼びやすくラップする。引数は(関数名, 戻り値型, [引数型...])

パターン2: CMakeプロジェクト

実際のC/C++ライブラリはCMakeでビルドされることが多い。Emscriptenにはemcmakeというラッパーがある。

mkdir build && cd build
emcmake cmake .. -DCMAKE_BUILD_TYPE=Release
emmake make

emcmakeがCMakeの内部変数(CMAKE_C_COMPILER等)をemccに差し替えてくれるので、CMakeのプロジェクトがそのままWASM向けにビルドできる。

laz-perfの実例

mkdir build && cd build
emcmake cmake .. \
  -DCMAKE_BUILD_TYPE=Release \
  -DBUILD_SHARED_LIBS=OFF
emmake make

-DBUILD_SHARED_LIBS=OFFは重要。デフォルトだと.so(共有ライブラリ)を生成しようとしてリンクに失敗する。WASMビルドでは常にスタティックリンクにする。

パターン3: Makefileプロジェクト

Makefileベースなら、コンパイラを差し替えるだけで済むこともある。

make CC=emcc CXX=em++

ただし、プラットフォーム固有のフラグ(-lpthread等)が入っているとエラーになるので、Makefileの修正が必要な場合もある。


Embindとバインディング

なぜバインディングが必要か

C/C++の関数をJSから呼ぶには、「どの関数を、どういう引数で公開するか」を定義する必要がある。Emscriptenには2つの方法がある。

方法特徴
EXPORTED_FUNCTIONSシンプル。C関数をそのまま公開
EmbindC++クラス、オーバーロード、std::stringなどをそのままJSに公開できる

Embindの基本

#include <emscripten/bind.h>

class MyCompressor {
public:
    MyCompressor(int level) : level_(level) {}
    std::vector<uint8_t> compress(const std::string& data);
private:
    int level_;
};

EMSCRIPTEN_BINDINGS(my_module) {
    emscripten::class_<MyCompressor>("MyCompressor")
        .constructor<int>()
        .function("compress", &MyCompressor::compress);
}

JS側:

const Module = await createModule();
const compressor = new Module.MyCompressor(6);
const result = compressor.compress(data);
compressor.delete(); // C++オブジェクトなので手動で解放が必要

.delete()を忘れない

Embindで作ったオブジェクトはC++のヒープに確保される。JavaScriptのGCは管理してくれないので、使い終わったら.delete()を呼ぶ。忘れるとメモリリークする。

laz-perfでの実例

laz-perfのWASMビルドでは、MemFileWriterChunkCompressorをEmbindで公開した。C++側でbasic_fileがprotectedコンストラクタだったため、MemFileWriterbasic_fileを継承する形で回避した。また、close()がJSの予約語と衝突するためfinish()にリネームした。

こういう「C++の設計とJS側の制約の擦り合わせ」がWASMビルドの実作業の大半を占める。


メモリ管理

WASMのリニアメモリ

WASMは1つの巨大なArrayBuffer(リニアメモリ)上で動作する。C/C++のmallocはこのバッファ内のアドレスを返す。

// JS側からWASMのメモリにアクセス
const ptr = Module._malloc(1024);          // 1024バイト確保
const heap = new Uint8Array(Module.HEAPU8.buffer, ptr, 1024);
heap.set(someData);                        // データを書き込み
Module._some_function(ptr, 1024);          // ポインタを渡してC関数を呼ぶ
Module._free(ptr);                         // 解放

HEAPU8, HEAP32 etc.

WASMのリニアメモリには型付きビューでアクセスする。

ビュー用途
HEAPU8Uint8Arrayバイト列の読み書き
HEAP16Int16Array16bit整数
HEAP32Int32Array32bit整数
HEAPF32Float32Array単精度浮動小数点
HEAPF64Float64Array倍精度浮動小数点

点群データ(XYZ座標 = Float64の配列)をWASMに渡すならHEAPF64を使う、といった具合。

よくある罠: メモリの成長

WASMのメモリは動的に成長できる。成長するとArrayBuffer新しいバッファに置き換わるため、以前取得したHEAPU8等のビューが無効になる。

// NG: ビューを保持し続ける
const view = Module.HEAPU8;
Module._some_heavy_function();  // 内部でメモリが成長する可能性
view[0] = 42;                   // 古いバッファを参照 → 無効

// OK: 使う直前に毎回取得
Module._some_heavy_function();
Module.HEAPU8[ptr] = 42;       // 最新のビューを使う

ビルドオプション(よく使うもの)

最適化

emcc -O2 main.c -o main.js    # 推奨(サイズと速度のバランス)
emcc -O3 main.c -o main.js    # 最大最適化(ビルド時間が伸びる)
emcc -Os main.c -o main.js    # サイズ最優先
emcc -Oz main.c -o main.js    # サイズ最小(速度を犠牲にする場合あり)

ブラウザ配信なら-O2-Osが現実的。laz-perfでは250KBに収まったが、OpenCVのWASMカスタムビルドでは2.4MBの軽量化が課題だった。不要な機能を削ることがサイズ削減の基本。

MODULARIZE

emcc main.c -o main.js -s MODULARIZE=1 -s EXPORT_NAME='createModule'

デフォルトではグローバルにModuleオブジェクトが生成される。MODULARIZE=1にすると、ファクトリ関数としてexportされる。

import createModule from './main.js';
const Module = await createModule();

ES Modulesやバンドラーと組み合わせるなら必須。

ALLOW_MEMORY_GROWTH

emcc main.c -o main.js -s ALLOW_MEMORY_GROWTH=1

WASMのメモリを動的に拡張可能にする。デフォルトでは固定サイズ(16MB)。大きなファイルを扱うなら有効にしておく。

その他のよく使うフラグ

フラグ意味
-s FILESYSTEM=0仮想ファイルシステムを無効化(サイズ削減)
-s SINGLE_FILE=1.wasmを.jsにBase64で埋め込む(配信が楽)
-s WASM=1WASM出力(デフォルトで有効)
-s ASSERTIONS=1デバッグ用アサーション有効化
-s TOTAL_MEMORY=33554432初期メモリサイズ(バイト単位)
--no-entrymain()がないライブラリ向け
-lembindEmbindを使う場合に必要

WASMの「使う側」vs「作る側」

WASMとの関わり方は大きく2つに分かれる。

使う側(ビルド済みWASMを利用する)

  • FFmpeg WASM — 動画エンコード/デコード
  • DuckDB WASM — ブラウザ内SQL
  • Tesseract.js — OCR
  • OpenCV.js — 画像処理(公式ビルド)
  • sql.js — SQLite

npmで入れて、APIを呼ぶだけ。WASMの内部を意識する必要はほぼない。Video CompressorでFFmpeg WASMを使ったのはこのパターン。

作る側(C/C++ライブラリを自分でWASMにする)

  • 既存の公式WASMビルドが存在しない
  • 公式ビルドが大きすぎて、必要な機能だけの軽量版が欲しい
  • バインディングをカスタマイズしたい

laz-perfのカスタムビルドや、OpenCVの軽量ビルド(inpainting機能だけ抽出)がこのパターン。

「作る側」に回ると、Emscriptenのビルドオプション、Embind、メモリ管理、C++側のコード読解が必要になる。難易度は上がるが、ブラウザでできることの幅が一気に広がる


実践: 最小構成のWASMビルド

理解を確認するための最小例。

1. Cコードを書く

// fibonacci.c
#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE
int fibonacci(int n) {
    if (n <= 1) return n;
    int a = 0, b = 1;
    for (int i = 2; i <= n; i++) {
        int temp = a + b;
        a = b;
        b = temp;
    }
    return b;
}

2. ビルド

emcc fibonacci.c -o fibonacci.js \
  -s MODULARIZE=1 \
  -s EXPORT_NAME='createFibonacci' \
  -s EXPORTED_FUNCTIONS='["_fibonacci"]' \
  -s EXPORTED_RUNTIME_METHODS='["ccall"]' \
  -O2

3. ブラウザから呼ぶ

import createFibonacci from './fibonacci.js';

const Module = await createFibonacci();
const result = Module.ccall('fibonacci', 'number', ['number'], [40]);
console.log(result); // 102334155

これだけ。生成される.wasmは数KBで、ブラウザで即座に動く。


WASMを使うべき場面・使わなくていい場面

使うべき場面

  • 既存のC/C++ライブラリをブラウザに持ってきたい(圧縮、画像処理、フォーマット変換)
  • 数学的に複雑なアルゴリズムで、JSの実装が非現実的or不正確になりうる
  • 大量データの処理でJSのGCが邪魔になる

使わなくていい場面

  • DOM操作やUI処理(JSの方が速い)
  • 普通のWebアプリのビジネスロジック
  • 小さな計算(WASMの初期化コストの方が大きい)

「WASMにすれば速くなる」は半分正しくて半分間違い。JSでは実現不可能 or 非現実的な処理をブラウザに持ち込む手段として考える方が正確。


まとめ

概念一言
WebAssemblyブラウザで動くバイナリ実行形式
EmscriptenC/C++→WASMのクロスコンパイラ(ツールチェーン)
emcc / em++GCC/Clangに相当するコンパイルコマンド
グルーコードC/C++の世界とブラウザを橋渡しする自動生成JS
EmbindC++クラスをJSに公開するバインディング機構
リニアメモリWASMが使う1つの巨大なArrayBuffer
emsdkEmscriptenのバージョン管理・インストールツール

個人的な所感として、WASMの「作る側」で一番大変なのはEmscriptenの設定でもメモリ管理でもなく、元のC/C++コードを読んで、何をどう公開するか決める設計判断。laz-perfのビルドでも、protectedコンストラクタの回避やメソッド名の衝突解決が実作業の大半だった。

逆に言えば、C/C++のコードが読めて設計判断ができれば、Emscripten自体のハードルはそこまで高くない。