Webアプリケーションの進化はとどまるところを知りません。かつては静的なドキュメントを表示するためのプラットフォームであったWebは、今やデスクトップアプリケーションに匹敵する、あるいはそれを凌駕するほどの複雑で高性能な体験を提供する場となりました。3Dグラフィックス、リアルタイム動画編集、大規模なデータ可視化、物理シミュレーション、そして機械学習モデルの推論。これらの要求は、JavaScriptというWebの共通言語だけでは性能の限界に直面することがあります。この課題に対する最も強力な答えの一つが、WebAssembly(Wasm)です。
WebAssemblyは、モダンなWebブラウザで実行可能な、新しい種類のコードです。それは低レベルのアセンブリ言語に似たバイナリ命令形式であり、C、C++、Rustといった高性能なプログラミング言語のコンパイルターゲットとして設計されています。JavaScriptが動的で柔軟な高レベル言語であるのに対し、WebAssemblyは静的型付け、事前コンパイル、そして極めて高速な実行速度を特徴とします。これは、Webの表現力と、ネイティブコードの実行性能という、二つの世界の長所を融合させる技術です。
この技術がもたらす最大の恩恵の一つは、既存のコード資産の再利用です。世界には、長年にわたって開発され、テストされ、最適化されてきたC++のライブラリやアプリケーションが膨大に存在します。これらはゲームエンジン、科学技術計算、画像・音声処理、CADソフトウェアなど、計算集約的な分野で活躍してきた実績あるコードです。これらをWebアプリケーションで活用するためにゼロからJavaScriptで書き直すのは、非現実的な時間とコストを要するだけでなく、元のコードが持つ性能と安定性を再現できないリスクも伴います。WebAssemblyは、これらの貴重なC++資産を最小限の変更でWebプラットフォームに移植し、その性能をブラウザ上で解放するための架け橋となるのです。
本稿では、この変革的な技術の中心に位置するツールチェーン、Emscriptenに焦点を当てます。Emscriptenは、LLVMコンパイラ基盤を利用してC/C++コードをWebAssemblyにコンパイルするための、包括的で成熟したツールセットです。単なるコンパイラにとどまらず、C++の標準ライブラリ(libc++, libc)や、OpenGL(WebGL経由)、SDLといった一般的なライブラリのAPIをエミュレートし、C++開発者が慣れ親しんだ環境をWeb上で再現します。この記事を通じて、Emscriptenを用いて既存のC++コードをWebAssemblyモジュールに変換し、それをJavaScriptとシームレスに連携させ、現代的なWebアプリケーションに組み込むための実践的な知識と深い洞察を提供します。
第1章 WebAssemblyの核心概念:ブラウザ内の仮想CPU
WebAssemblyを効果的に活用するためには、その表面的な「速さ」だけでなく、その動作原理と設計思想を理解することが不可欠です。WebAssemblyは、ブラウザ内に存在する、安全かつ高速な仮想マシン(VM)と考えることができます。このVMは、特定のハードウェアやOSに依存しない、ポータブルな実行環境を提供します。
1.1 サンドボックス化された安全な実行環境
WebAssemblyの最も重要な設計原則の一つは「安全性」です。Webからダウンロードされたコードがユーザーのシステムに悪影響を及ぼすことを防ぐため、Wasmモジュールは厳格なサンドボックス内で実行されます。これは、Wasmコードが実行環境のメモリ空間やシステムリソースに直接アクセスできないことを意味します。
- DOMへのアクセス不可: Wasmは、HTML要素を直接操作したり、イベントを処理したりするAPIを持ちません。DOMの操作はすべて、JavaScriptを介して行う必要があります。
- ネットワーク・ファイルシステムへのアクセス不可: Wasmは、直接ネットワークリクエストを送信したり、ローカルファイルを読み書きしたりすることはできません。これらの機能も同様に、JavaScriptのAPI(
fetch
やFile System Access APIなど)を呼び出すことで実現します。 - 明確なインターフェース: Wasmモジュールと外部(JavaScript)とのやり取りは、明示的に定義されたインポートとエクスポートを介してのみ行われます。これにより、モジュールがどのような機能にアクセスしようとしているかが明確になり、セキュリティポリシーの適用が容易になります。
このサンドボックスモデルは、C++開発者にとって特に重要な概念です。ネイティブ環境では当たり前のように行っていたファイルI/Oやシステムコールは、WebAssemblyの世界では直接利用できません。Emscriptenは、これらの標準C/C++ライブラリ関数をエミュレートすることでギャップを埋めますが、その背後ではJavaScriptとの連携が行われていることを理解しておく必要があります。
1.2 線形メモリ(Linear Memory):WasmとJSの共有地
サンドボックス内で隔離されているWasmが、どのようにしてJavaScriptと複雑なデータをやり取りするのでしょうか。その答えが「線形メモリ」です。
WebAssemblyの各インスタンスは、WebAssembly.Memory
オブジェクトとして表現される、単一の連続したメモリブロックを割り当てられます。これはJavaScriptのArrayBuffer
と非常によく似たもので、バイトの巨大な配列と考えることができます。Wasmコード内のすべての変数、データ構造、そしてC++におけるヒープ領域(malloc
やnew
で確保される領域)は、この線形メモリ内に配置されます。
このモデルの重要な点は以下の通りです。
- ポインタからオフセットへ: C/C++におけるポインタは、WebAssemblyの世界では、この線形メモリの先頭からのバイトオフセット(整数のインデックス)として解釈されます。例えば、C++で
int* p = new int[10];
のように確保されたメモリは、線形メモリ内のある特定のオフセットに配置され、p
はそのオフセット値を保持します。 - JavaScriptからのアクセス: この線形メモリはJavaScriptからもアクセス可能です。JavaScript側では、
ArrayBuffer
をInt32Array
やFloat64Array
などの型付き配列ビュー(Typed Array View)でラップすることで、特定のオフセットにあるデータを直接読み書きできます。これにより、WasmとJSの間で大量のデータを高速にコピーレスで共有することが可能になります。 - 分離されたスタック: C++の関数呼び出しで使われるコールスタックは、この線形メモリとは別の、Wasmランタイムが管理する内部的な領域に存在します。これにより、JavaScriptからWasmのスタックを破壊するような攻撃を防いでいます。
線形メモリは、Wasmの性能と安全性を両立させるための核心的な仕組みです。C++の複雑なデータ構造をJavaScriptとやり取りする際には、この線形メモリを介して、データのエンコードとデコード(マーシャリング)を行う必要があり、これがWasmプログラミングにおける一つの大きなテーマとなります。
1.3 モジュールとインスタンス:設計図と実体
WebAssemblyのコードは、「モジュール」と「インスタンス」という二つの概念で管理されます。
- モジュール (
WebAssembly.Module
): これは、コンパイル済みのWasmコード(.wasm
ファイル)そのものです。モジュールはステートレス(状態を持たない)であり、コードの構造、エクスポートされる関数、インポートする関数、メモリ要件などが定義されています。これは、C++におけるクラス定義や、実行ファイルの.text
セクションのような「設計図」に相当します。モジュールは一度コンパイルされると、複数のインスタンスで再利用できます。 - インスタンス (
WebAssembly.Instance
): これは、モジュールを実体化したものです。インスタンスはステートフル(状態を持つ)であり、自身の線形メモリやテーブル(関数ポインタのテーブル)を保持します。JavaScriptからは、このインスタンスがエクスポートする関数を呼び出すことで、Wasmコードを実行します。これは、クラス定義からnew
で生成されたオブジェクトインスタンスに相当します。
通常、.wasm
ファイルをネットワークから取得し、ブラウザでコンパイルしてモジュールを生成し、そのモジュールからインスタンスを作成するという流れになります。Emscriptenが生成するJavaScriptの「グルーコード」は、このプロセスを自動化し、開発者が意識することなくWasmモジュールをロードして使えるようにしてくれます。
第2章 Emscripten開発環境の構築と最初のコンパイル
理論を学んだところで、次はいよいよ実践です。C++コードをWebAssemblyに変換するための強力な相棒、Emscriptenツールチェーンをセットアップし、最初の「Hello, World」をブラウザで動かしてみましょう。
2.1 Emscripten SDK (emsdk) のインストール
Emscriptenを導入する最も簡単で推奨される方法は、Emscripten SDK (emsdk) を利用することです。emsdkは、Emscripten本体だけでなく、特定のバージョンに必要なClang/LLVMコンパイラやNode.js、Pythonといった依存ツール群をまとめて管理してくれる便利なツールです。
Linux / macOS でのセットアップ手順:
- リポジトリのクローン:
git clone https://github.com/emscripten-core/emsdk.git cd emsdk
- 最新ツールの取得:
以下のコマンドで、推奨される最新バージョンのSDKツールをダウンロード・インストールします。
./emsdk install latest
- 最新ツールの有効化:
インストールしたツールを現在のセッションで利用可能にします。
./emsdk activate latest
- 環境変数の設定:
emcc
などのコマンドをターミナルから直接使えるように、環境変数を設定します。このコマンドを実行すると、~/.bash_profile
や~/.zshrc
などに追記すべき内容が表示されるので、それに従ってください。source ./emsdk_env.sh
新しいターミナルを開くたびにこのコマンドを実行するか、シェルの設定ファイル(
.bashrc
,.zshrc
など)に追記しておくことで、恒久的に設定できます。
Windows でのセットアップ手順:
Windowsでは、Git for WindowsやWindows Subsystem for Linux (WSL) を利用するのが一般的です。WSLを利用する場合、上記Linuxの手順とほぼ同じです。ここではGit for WindowsのGit Bashを使った手順を示します。
- リポジトリのクローンと移動:
git clone https://github.com/emscripten-core/emsdk.git cd emsdk
- ツールのインストールと有効化:
Windows用のコマンドは
.bat
ファイルになります。emsdk install latest emsdk activate latest
- 環境変数の設定:
emsdk_env.bat
このコマンドを実行すると、現在のコマンドプロンプトセッションでEmscriptenのコマンドが使えるようになります。
インストールが成功したか確認するために、ターミナルで以下のコマンドを実行してみましょう。バージョン情報が表示されれば成功です。
emcc --version
2.2 C++からWebAssemblyへのコンパイル:最初のステップ
環境が整ったので、簡単なC++プログラムをWebAssemblyにコンパイルしてみましょう。以下の内容でhello.cpp
というファイルを作成してください。
#include <iostream>
int main() {
std::cout << "Hello, WebAssembly from C++!" << std::endl;
return 0;
}
このファイルをコンパイルするには、g++
やclang++
の代わりにemcc
コマンドを使用します。
emcc hello.cpp -o hello.html
この一行のコマンドが、Emscriptenの魔法の始まりです。-o hello.html
というオプションを指定することで、EmscriptenはWebAssemblyモジュールを実行するための完全なHTMLページを生成してくれます。コマンドが完了すると、カレントディレクトリに以下の3つのファイルが生成されます。
hello.html
: Wasmモジュールをロードし、実行結果を表示するためのHTMLファイル。hello.js
: WasmモジュールとブラウザのAPIを繋ぐ「グルーコード」。Wasmのロード、インスタンス化、標準出力のエミュレーションなど、複雑な処理を担います。hello.wasm
: コンパイルされたC++コード本体であるWebAssemblyバイナリファイル。
これらのファイルをブラウザで表示するためには、ローカルのWebサーバーを立てる必要があります。ブラウザのセキュリティポリシーにより、file://
プロトコルではWasmモジュールを正しくロードできない場合があるためです。Pythonがインストールされていれば、以下のコマンドで簡単にサーバーを起動できます。
# Python 3
python -m http.server
# Python 2
python -m SimpleHTTPServer
サーバーが起動したら、ブラウザで http://localhost:8000/hello.html
を開いてみてください。ページのテキストエリアに "Hello, WebAssembly from C++!" と表示され、開発者コンソールにも同じメッセージが出力されているはずです。std::cout
による出力が、EmscriptenのグルーコードによってブラウザのコンソールやHTML要素にリダイレクトされたのです。
2.3 生成ファイルの役割分担
先ほどのシンプルなコンパイルで生成されたファイル群は、Emscriptenのアーキテクチャを理解する上で非常に重要です。
.wasm
ファイル: これは純粋な計算ロジックの塊です。C++コードが変換された低レベルの命令セットが含まれていますが、それ自体は外部の世界と直接対話する能力を持ちません。.js
ファイル (グルーコード): このファイルがWasmとWeb環境の橋渡し役です。具体的には、以下のような多岐にわたる役割を担います。- Wasmモジュールのロードとコンパイル:
.wasm
ファイルを非同期にフェッチし、WebAssembly.instantiateStreaming
などを用いて効率的にインスタンス化します。 - インポートオブジェクトの提供: Wasmモジュールが要求する外部関数(例えば、
emscripten_memcpy_big
のような内部ヘルパー関数や、時間を取得する_sys_time
など)をJavaScriptで実装し、インポートオブジェクトとしてWasmインスタンスに提供します。 - 標準ライブラリのエミュレーション:
printf
やstd::cout
による出力をコンソールに出力したり、fopen
やfread
といったファイルI/Oをメモリ上の仮想ファイルシステム(MEMFS)でエミュレートしたりします。 - APIの公開: C++側でエクスポートした関数を、JavaScriptから呼び出しやすい形のAPI(例:
Module._my_function
)として公開します。
- Wasmモジュールのロードとコンパイル:
.html
ファイル: これは主にデバッグと簡単なデモのためのテンプレートです。Wasmのロード状況を表示したり、標準出力を受け取るためのテキストエリアを提供したりします。実際のアプリケーション開発では、このHTMLは使わず、既存のWebフロントエンドフレームワーク(React, Vue, Angularなど)にグルーコードとWasmファイルを組み込むことになります。
このように、Emscriptenは単にC++をWasmに変換するだけでなく、C++プログラムがWeb環境で「期待通りに」動作するための、広範なランタイム環境とサポートライブラリを提供してくれるのです。
第3章 JavaScriptとC++の連携:二つの世界の対話術
WebAssemblyモジュールをWebアプリケーションに組み込むということは、JavaScriptのコードとC++のコードが互いに連携し、データを交換する必要があるということです。Emscriptenは、この異種言語間のコミュニケーションを円滑にするための多様なメカニズムを提供しています。
3.1 JavaScriptからC++関数を呼び出す
最も基本的な連携は、JavaScriptからC++で実装された特定の関数を呼び出すことです。これにより、計算コストの高い処理をWasmにオフロードできます。Emscriptenでは主にccall
とcwrap
という二つの方法が提供されます。
まず、以下のようなC++コード(calculator.cpp
)を用意します。ここでは、外部から呼び出される関数にEMSCRIPTEN_KEEPALIVE
というアトリビュートを付与しています。これは、Emscriptenのコンパイラが最適化の過程で「どこからも呼び出されていない」と判断して関数を削除してしまう(Dead Code Elimination)のを防ぐための重要な印です。
#include <emscripten.h>
#include <cmath>
extern "C" {
EMSCRIPTEN_KEEPALIVE
int add(int a, int b) {
return a + b;
}
EMSCRIPTEN_KEEPALIVE
double distance(double x1, double y1, double x2, double y2) {
double dx = x1 - x2;
double dy = y1 - y2;
return std::sqrt(dx * dx + dy * dy);
}
} // extern "C"
extern "C"
ブロックで囲っているのは、C++の名前マングリング(Name Mangling)を防ぎ、関数名がC言語の形式で素直にエクスポートされるようにするためです。これにより、JavaScriptから関数名を指定して呼び出すのが容易になります。
このコードをコンパイルします。今回はHTMLを生成せず、JavaScriptから直接利用できる.js
ファイルと.wasm
ファイルのみを生成します。また、エクスポートしたい関数名を明示的に指定する-s EXPORTED_FUNCTIONS
オプションを使います。
emcc calculator.cpp -o calculator.js -s EXPORTED_FUNCTIONS="['_add', '_distance']"
EXPORTED_FUNCTIONS
に指定する関数名にはアンダースコア(_
)を付けます。これはEmscriptenがCの関数を内部的に識別するための慣習です。
3.1.1 ccall
: 単発の関数呼び出し
ccall
は、指定したC++関数を一度だけ呼び出すためのシンプルな方法です。
// calculator.jsを読み込んだHTMLファイル内のスクリプト
Module.onRuntimeInitialized = () => {
// add関数を呼び出す
const result_add = Module.ccall(
'add', // C++の関数名
'number', // 戻り値の型
['number', 'number'], // 引数の型の配列
[10, 22] // 引数の値の配列
);
console.log('add(10, 22) =', result_add); // 32
// distance関数を呼び出す
const result_dist = Module.ccall(
'distance',
'number',
['number', 'number', 'number', 'number'],
[0, 0, 3, 4]
);
console.log('distance(0,0, 3,4) =', result_dist); // 5
};
Module.onRuntimeInitialized
は、Wasmモジュールの非同期読み込みと初期化がすべて完了した後に実行されるコールバック関数です。Wasm関数を安全に呼び出すためには、必ずこのコールバック内、あるいはそれ以降のタイミングでコードを実行する必要があります。
ccall
の引数で指定する型は、'number'
, 'string'
, 'boolean'
, 'array'
などがありますが、基本は'number'
(C++の数値型全般に対応)と'string'
(C++のchar*
に対応)です。戻り値がvoid
の場合はnull
を指定します。
3.1.2 cwrap
: 関数をJavaScriptの関数としてラップ
同じC++関数を何度も呼び出す場合、毎回ccall
を使うのは冗長ですし、引数の型情報を毎回解釈するためわずかなオーバーヘッドがあります。cwrap
は、C++関数を一度だけラップし、再利用可能なJavaScript関数を生成します。
Module.onRuntimeInitialized = () => {
// cwrapでJavaScript関数を生成
const js_add = Module.cwrap(
'add',
'number',
['number', 'number']
);
const js_distance = Module.cwrap(
'distance',
'number',
['number', 'number', 'number', 'number']
);
// 生成した関数を通常のJavaScript関数のように呼び出す
console.log('js_add(100, 25) =', js_add(100, 25)); // 125
console.log('js_add(5, -5) =', js_add(5, -5)); // 0
console.log('js_distance(0,0, 5,12) =', js_distance(0, 0, 5, 12)); // 13
};
cwrap
は、アプリケーションの初期化段階で一度だけ実行し、得られたJavaScript関数を保持しておくのが効率的な使い方です。これにより、コードの可読性も向上します。
3.2 C++とJavaScript間での複雑なデータ交換
数値の受け渡しは簡単ですが、実際のアプリケーションでは文字列や配列、構造体といったより複雑なデータを交換する必要があります。ここで、第1章で学んだ「線形メモリ」の概念が重要になります。
3.2.1 文字列 (char*
) の受け渡し
JavaScriptの文字列とC++のchar*
(ヌル終端文字列)は形式が異なるため、変換が必要です。データ交換の基本は、Wasmの線形メモリを仲介することです。
C++側のコード (string_util.cpp
):
#include <emscripten.h>
#include <string>
#include <algorithm>
extern "C" {
// 受け取った文字列を大文字にして返す
// 注意:呼び出し元は返されたポインタのメモリを解放する責任がある
EMSCRIPTEN_KEEPALIVE
const char* to_uppercase(const char* input) {
std::string str(input);
std::transform(str.begin(), str.end(), str.begin(), ::toupper);
// Emscriptenのヒープにメモリを確保し、結果をコピー
char* result = new char[str.length() + 1];
strcpy(result, str.c_str());
return result;
}
// C++側で確保したメモリを解放するための関数
EMSCRIPTEN_KEEPALIVE
void free_string(void* ptr) {
delete[] static_cast<char*>(ptr);
}
}
コンパイル:
emcc string_util.cpp -o string_util.js -s EXPORTED_FUNCTIONS="['_to_uppercase', '_free_string']"
JavaScript側のコード:
Module.onRuntimeInitialized = async () => {
const to_uppercase = Module.cwrap('to_uppercase', 'number', ['string']);
const free_string = Module.cwrap('free_string', null, ['number']);
const inputString = "Hello from JavaScript!";
// to_uppercaseを呼び出す。
// 'string'型を指定すると、Emscriptenが自動で
// 1. Wasmのヒープにメモリを確保 (_malloc)
// 2. JavaScript文字列をUTF-8にエンコードして書き込み
// 3. そのメモリアドレス (ポインタ) をC++関数に渡す
// という処理を行ってくれる。
const resultPointer = to_uppercase(inputString);
// C++関数が返したポインタは、線形メモリ内のオフセット (数値)
console.log("Returned pointer:", resultPointer);
// Module.UTF8ToString() を使って、ポインタからJavaScript文字列に変換
const resultString = Module.UTF8ToString(resultPointer);
console.log("Result from C++:", resultString); // "HELLO FROM JAVASCRIPT!"
// C++側で new[] したメモリは、必ずJavaScript側から解放する必要がある
free_string(resultPointer);
console.log("Memory freed.");
};
この例は、Wasmとのデータ連携における非常に重要なパターンを示しています。
- JavaScriptからWasmにデータを渡す際は、Wasmの線形メモリ上にデータをコピーし、そのポインタ(オフセット)を渡す。
- WasmからJavaScriptにデータを返す際は、Wasmが線形メモリ上に結果を書き込み、そのポインタを返す。JavaScriptは、そのポインタを元に線形メモリからデータを読み出す。
- Wasm側で動的に確保されたメモリ(
malloc
やnew
)は、Wasmのガベージコレクタの対象外であるため、不要になったら必ず明示的に解放(free
やdelete
)する関数を別途用意し、JavaScriptから呼び出す必要がある。これを怠るとメモリリークの原因となる。
3.2.2 配列 (数値) の受け渡し
数値配列のような大きなデータを扱う場合、パフォーマンスが重要になります。線形メモリを直接操作することで、データのコピーを最小限に抑え、高速なやり取りが可能です。
C++側のコード (array_proc.cpp
):
#include <emscripten.h>
extern "C" {
// float配列の各要素を2倍にする
EMSCRIPTEN_KEEPALIVE
void scale_array(float* arr, int len) {
for (int i = 0; i < len; ++i) {
arr[i] *= 2.0f;
}
}
}
コンパイル:
emcc array_proc.cpp -o array_proc.js -s EXPORTED_FUNCTIONS="['_scale_array', '_malloc', '_free']"
ここでは、JavaScript側でメモリを直接確保・解放するために、Cの標準ライブラリ関数である_malloc
と_free
もエクスポートしています。
JavaScript側のコード:
Module.onRuntimeInitialized = () => {
const scale_array = Module.cwrap('scale_array', null, ['number', 'number']);
// 処理したいJavaScriptの配列
const data = new Float32Array([1.0, 2.5, 3.0, 4.25, 5.5]);
const N = data.length;
const bytes = data.byteLength;
// 1. Wasmのヒープに、データを格納するのに十分なメモリを確保
const bufferPtr = Module._malloc(bytes);
// 2. 確保したメモリ領域を、JavaScriptの型付き配列ビューでラップ
// Module.HEAPF32 は、Wasmの線形メモリ全体をFloat32Arrayとして見なすビュー
// .subarray() を使って、確保した領域だけを切り出す
const wasmHeap = new Float32Array(Module.HEAPF32.buffer, bufferPtr, N);
// 3. JavaScriptのデータを、Wasmのヒープ上のビューにコピー
wasmHeap.set(data);
console.log("Before (in Wasm heap):", wasmHeap);
// 4. Wasm関数を呼び出し、ポインタと長さを渡す
scale_array(bufferPtr, N);
// 5. Wasm関数によって変更されたヒープ上のデータを読み出す
// wasmHeapは同じメモリ領域を指しているので、値が更新されている
console.log("After (in Wasm heap):", wasmHeap);
// 6. 確保したメモリを解放
Module._free(bufferPtr);
console.log("Buffer freed.");
// 必要であれば、結果を新しいJavaScriptの配列にコピーして利用
const resultArray = new Float32Array(wasmHeap);
};
この方法は、文字列の場合よりも低レベルですが、より高い柔軟性とパフォーマンスを提供します。Module.HEAPU8
, Module.HEAP32
, Module.HEAPF64
など、様々なデータ型に対応するヒープビューが用意されており、これらを駆使してWasmの線形メモリを直接読み書きするのが、高度な連携における基本となります。
第4章 Embindによる現代的な連携手法
ccall
やcwrap
、そして手動でのメモリ管理は強力ですが、コードが煩雑になりがちで、特にオブジェクト指向のC++コードを扱うのは困難です。そこで登場するのがEmbindです。
Embindは、C++のテンプレートメタプログラミングを駆使して、最小限の記述でC++の関数、クラス、列挙型などをJavaScriptに「バインディング」するためのEmscriptenの機能です。Embindを使うと、JavaScript側でC++のクラスをnew
したり、メソッドを呼び出したり、プロパティにアクセスしたりすることが、ごく自然な構文で行えるようになります。
4.1 Embindの基本:関数と値のバインディング
まずは簡単な関数をEmbindで公開してみましょう。(embind_basic.cpp
)
#include <emscripten/bind.h>
#include <string>
using namespace emscripten;
std::string greet(const std::string& name) {
return "Hello, " + name + "!";
}
float get_pi() {
return 3.1415926535f;
}
// EMSCRIPTEN_BINDINGSブロック内に、公開したいものを記述する
EMSCRIPTEN_BINDINGS(my_module) {
function("greet", &greet);
function("getPi", &get_pi);
constant("MY_CONSTANT", 123);
}
コンパイル時には--bind
フラグが必要です。
emcc --bind -o embind_basic.js embind_basic.cpp
JavaScript側では、Module
オブジェクトにバインドした名前で直接アクセスできます。
Module.onRuntimeInitialized = () => {
const message = Module.greet("Embind");
console.log(message); // "Hello, Embind!"
const pi = Module.getPi();
console.log("Pi:", pi); // 3.1415927410125732
const c = Module.MY_CONSTANT;
console.log("Constant:", c); // 123
};
ccall
やcwrap
のように型情報を文字列で指定する必要がなく、C++の関数ポインタを渡すだけです。文字列の受け渡しも、std::string
を指定すればEmbindが自動的に内部で変換処理を行ってくれるため、手動でのメモリ管理は不要です。非常にクリーンで直感的になっていることがわかります。
4.2 C++クラスのバインディング
Embindの真価は、クラスのバインディングで発揮されます。C++で定義したクラスを、JavaScriptのクラスのように自然に扱うことができます。
C++側のコード (embind_class.cpp
):
#include <emscripten/bind.h>
#include <string>
class MyVector {
public:
MyVector(float x, float y) : x_(x), y_(y) {}
float length() const {
return sqrt(x_ * x_ + y_ * y_);
}
void normalize() {
float len = length();
if (len > 0) {
x_ /= len;
y_ /= len;
}
}
float get_x() const { return x_; }
void set_x(float x) { x_ = x; }
float get_y() const { return y_; }
void set_y(float y) { y_ = y; }
static std::string description() {
return "A 2D vector class";
}
private:
float x_;
float y_;
};
EMSCRIPTEN_BINDINGS(my_class_module) {
class_<MyVector>("MyVector")
.constructor<float, float>()
.function("length", &MyVector::length)
.function("normalize", &MyVector::normalize)
.property("x", &MyVector::get_x, &MyVector::set_x)
.property("y", &MyVector::get_y, &MyVector::set_y)
.class_function("description", &MyVector::description);
}
コンパイル:
emcc --bind -o embind_class.js embind_class.cpp
JavaScript側のコード:
Module.onRuntimeInitialized = () => {
// 静的メソッドの呼び出し
console.log(Module.MyVector.description()); // "A 2D vector class"
// コンストラクタでインスタンス生成
const v1 = new Module.MyVector(3, 4);
// プロパティへのアクセス
console.log("Initial vector:", v1.x, v1.y); // 3 4
// メソッドの呼び出し
console.log("Length:", v1.length()); // 5
// プロパティの変更
v1.x = 5;
v1.y = 12;
console.log("New length:", v1.length()); // 13
v1.normalize();
console.log("Normalized vector:", v1.x, v1.y);
console.log("Length after normalize:", v1.length()); // 1
// 重要: C++側で確保されたインスタンスは手動で解放する必要がある
v1.delete();
};
Embindは、class_
構文を用いて、コンストラクタ(.constructor
)、メンバ関数(.function
)、プロパティ(.property
)、静的メンバ関数(.class_function
)などを流れるようにバインドできます。JavaScript側では、まるでネイティブのJSクラスを扱うかのような直感的な操作が可能です。
ただし、一つ注意点があります。Embindで生成されたC++オブジェクトは、JavaScriptのガベージコレクションの対象にはなりません。JavaScript側でオブジェクトへの参照がなくなっても、C++側のインスタンスはメモリに残り続けます。そのため、不要になったオブジェクトは、必ず.delete()
メソッドを呼び出して明示的に解放する必要があります。これを怠るとメモリリークに繋がります。
Embindは、複雑なC++ APIをWebフロントエンドに公開する際の第一選択肢と言えるでしょう。開発効率とコードの可読性を劇的に向上させ、C++とJavaScriptの境界をより滑らかなものにしてくれます。
第5章 ファイルシステム:ブラウザ内の永続性
多くの既存C++アプリケーションは、設定ファイル、ユーザーデータ、アセットなどを読み書きするために、標準的なファイルI/O(fopen
, fread
, fwrite
など)に依存しています。しかし、ブラウザのサンドボックス環境には、デスクトップOSのような直接的なファイルシステムは存在しません。Emscriptenは、このギャップを埋めるために、巧妙な仮想ファイルシステムを提供します。
5.1 MEMFS: インメモリファイルシステム
デフォルトで、EmscriptenはMEMFSと呼ばれる純粋なインメモリファイルシステムを構築します。これは、アプリケーションが実行されている間だけ存在する一時的なファイルシステムです。C++コードがfopen("data.txt", "w")
を実行すると、実際には物理的なディスクではなく、JavaScriptのヒープ内に確保されたメモリブロックにファイルが作成されます。
この仕組みにより、ファイルI/Oを使用する多くのC++コードを、一切変更することなくWebAssemblyにコンパイルして実行できます。しかし、MEMFS上のデータは、ページがリロードされたり閉じられたりすると全て消えてしまいます。
5.2 ファイルのプリロードとエンベディング
アプリケーションが必要とする設定ファイルやアセットファイルを、実行時に利用可能にするにはどうすればよいでしょうか。一つの方法は、コンパイル時にファイルを仮想ファイルシステムに「プリロード」することです。
--preload-file
オプションを使用すると、指定したファイルやディレクトリを.data
という名前のバイナリパッケージにまとめ、実行時にMEMFS上に展開することができます。
例として、assets/config.json
というファイルがあるとします。
emcc my_app.cpp -o my_app.html --preload-file assets
このコマンドは、assets
ディレクトリ全体をパッケージ化します。生成されたmy_app.html
をブラウザで開くと、まずmy_app.data
ファイルが非同期でダウンロードされ、完了するとMEMFSの/assets/config.json
にファイルが配置されます。その後、C++コードからfopen("/assets/config.json", "r")
のようにしてファイルにアクセスできるようになります。
5.3 IDBFS: ブラウザのIndexedDBによる永続化
ユーザーが生成したデータを保存し、次回訪問時にもそれを読み込めるようにするには、インメモリのMEMFSだけでは不十分です。ここで活躍するのがIDBFSです。IDBFSは、Emscriptenの仮想ファイルシステムとブラウザのIndexedDB APIを同期させる仕組みです。
IndexedDBは、ブラウザが提供するキー・バリュー型の永続的なストレージであり、大量の構造化データをクライアントサイドに保存できます。IDBFSを使うことで、C++コードがファイルに書き込んだ内容をIndexedDBに保存し、次回起動時にそれをMEMFSに復元することができます。
IDBFSを有効にするには、コンパイルオプションと、マウント処理を行うJavaScriptコードが必要です。
コンパイルオプション:
emcc persistence.cpp -o persistence.html -s 'FILESYSTEM=1'
C++コード (persistence.cpp
):
#include <iostream>
#include <fstream>
#include <string>
void write_data() {
std::ofstream ofs("/data/user_profile.txt");
if (ofs) {
ofs << "User: Player1" << std::endl;
ofs << "Score: 12345" << std::endl;
std::cout << "Wrote to /data/user_profile.txt" << std::endl;
}
}
void read_data() {
std::ifstream ifs("/data/user_profile.txt");
if (ifs) {
std::cout << "Reading from /data/user_profile.txt:" << std::endl;
std::string line;
while (std::getline(ifs, line)) {
std::cout << line << std::endl;
}
} else {
std::cout << "Could not open /data/user_profile.txt for reading." << std::endl;
}
}
int main() {
read_data(); // 起動時に読み込み試行
write_data(); // データを書き込み
return 0;
}
JavaScript側の設定:
Emscriptenが生成するModule
オブジェクトが初期化される前に、ファイルシステムの設定を行う必要があります。
var Module = {
// onRuntimeInitialized の前に実行される
preRun: [],
postRun: [],
print: (function() { /* ...出力処理... */ })(),
printErr: function(text) { /* ...エラー出力処理... */ },
// ファイルシステムが準備完了した後の処理
onReady: function() {
console.log("Emscripten runtime ready.");
// 初期同期: IndexedDBからMEMFSへデータをロード
FS.syncfs(true, function (err) {
if (err) {
console.error("Initial syncfs failed:", err);
} else {
console.log("Initial syncfs complete.");
// C++のmain関数がここで実行される
}
});
},
// アプリケーション終了時や定期的な保存
// (ここでは例として、main関数終了後に保存)
postRun: [function() {
console.log("main() has finished. Syncing to persistent storage.");
// 同期: MEMFSからIndexedDBへデータを保存
FS.syncfs(false, function (err) {
if (err) {
console.error("Persistent syncfs failed:", err);
} else {
console.log("Persistent syncfs complete.");
}
});
}]
};
// --- Emscriptenが生成したpersistence.jsを読み込むスクリプトタグ ---
// <script async src="persistence.js"></script>
この設定により、以下の流れが実現されます。
- Wasmランタイムが初期化されると、まず
onReady
が呼ばれる前に、永続化したいディレクトリをIDBFSとしてマウントします。FS.mount(IDBFS, {}, '/data');
onReady
内で、最初のFS.syncfs(true, ...)
が実行され、IndexedDBに保存されているデータがMEMFSの/data
ディレクトリに復元されます。- C++の
main
関数が実行されます。初回実行時はread_data
は失敗しますが、write_data
によってMEMFS上にファイルが作成されます。 main
関数が終了すると、postRun
で2回目のFS.syncfs(false, ...)
が実行され、MEMFS上の/data
ディレクトリの変更内容がIndexedDBに書き込まれます。- 次回ページをリロードすると、ステップ2でIndexedDBからデータが復元されるため、
read_data
は成功し、前回の実行内容を読み込むことができます。
IDBFSは、Webアプリケーションにデスクトップアプリケーションのようなデータの永続性をもたらすための、非常に強力な機能です。
第6章 パフォーマンス最適化とビルド設定
WebAssemblyの大きな魅力はそのパフォーマンスですが、最大限の性能を引き出すには、適切なコンパイルオプションの選択が不可欠です。Emscriptenは、GCCやClangと同様の最適化フラグを提供しており、これらを使い分けることで、実行速度とファイルサイズのバランスを調整できます。
6.1 最適化レベルフラグ
-O
フラグで最適化レベルを指定します。
-O0
: 最適化なし。コンパイルが最も速く、デバッグ情報が豊富です。開発中のデバッグに最適ですが、生成されるコードは大きく、低速です。-O1
: 基本的な最適化。コードサイズとパフォーマンスをある程度改善します。-O2
: より強力な最適化。一般的に推奨されるレベルで、パフォーマンスが大幅に向上します。-O3
: 最も強力な最適化。-O2
に加え、ループのアンローリングや関数のインライン化などを積極的に行います。最高のパフォーマンスを発揮する可能性がありますが、コードサイズが増加し、コンパイル時間も長くなります。-Os
: コードサイズを優先して最適化。パフォーマンスをあまり犠牲にすることなく、ファイルサイズを小さく抑えたい場合に有効です。-Oz
:-Os
よりもさらに積極的にコードサイズを削減します。モバイル環境など、ダウンロードサイズが非常に重要な場合に適しています。
リリースビルドの典型的なコマンド:
emcc my_app.cpp -o my_app.js -O3 --bind
-O2
以上を指定すると、前述したEMSCRIPTEN_KEEPALIVE
やEXPORTED_FUNCTIONS
で明示的に指定されていない関数は、Dead Code Elimination(未使用コードの削除)によって最終的なバイナリから取り除かれます。これにより、ファイルサイズが劇的に削減されます。
6.2 モジュール分割と非同期ロード
大規模なアプリケーションでは、すべてのWasmコードを最初に一括でダウンロードするのは非効率です。Emscriptenは、DLL(ダイナミックリンクライブラリ)のように、Wasmモジュールを分割して動的にロードする機能もサポートしています。
MAIN_MODULE
: アプリケーションの本体となるメインモジュール。起動時にロードされます。SIDE_MODULE
: メインモジュールから動的にロードされるサブモジュール。
例えば、画像処理のコア機能と、あまり使われない特殊効果フィルター機能を別のモジュールに分割することができます。
サイドモジュール (effects.cpp
) のコンパイル:
emcc effects.cpp -O3 -s SIDE_MODULE=1 -o effects.wasm
-s SIDE_MODULE=1
を指定すると、.js
グルーコードなしで.wasm
ファイルのみが生成されます。
メインモジュール (main.cpp
) のコンパイル:
emcc main.cpp -O3 -s MAIN_MODULE=1 -o main.js
メインモジュールのC++コード内から、dlopen()
やdlsym()
といったPOSIX互換のAPIを使って、サイドモジュールを非同期にロードし、その中の関数へのポインタを取得することができます。これにより、ユーザーが必要とする機能だけをオンデマンドでロードする、より洗練されたアプリケーションアーキテクチャを構築できます。
6.3 SIMDとマルチスレッディング
WebAssemblyは、現代のCPUが持つ高度な機能を活用するための拡張仕様もサポートしています。
- SIMD (Single Instruction, Multiple Data): 128ビットのレジスタを使い、複数のデータ(例: 4つの32ビット浮動小数点数)に対して単一の命令で並列に演算を行う機能です。画像処理、物理演算、機械学習など、ベクトルや行列の計算が多用される分野で劇的なパフォーマンス向上をもたらします。Emscriptenでは
-msimd128
フラグを有効にすることで、コンパイラが自動的にSIMD命令を生成するようになります。 - マルチスレッディング (pthreads): Web Workersをベースにしたpthreads APIのエミュレーションにより、C++の
std::thread
やpthreads
を使ったマルチスレッドプログラミングをWeb上で実現できます。これにより、重い計算処理をバックグラウンドスレッドに逃がし、UIの応答性を維持することが可能になります。-s USE_PTHREADS=1
フラグで有効にできますが、サーバー側でSharedArrayBuffer
を有効にするためのCOOP/COEPヘッダーの設定が必要になるなど、いくつかの制約があります。
これらの高度な機能は、WebAssemblyが単なる高速なJavaScriptの代替ではなく、真にネイティブレベルのパフォーマンスをWebにもたらすためのプラットフォームであることを示しています。
結論:Webの新たなフロンティア
WebAssemblyとEmscriptenは、Web開発のパラダイムを大きく変える可能性を秘めた技術です。これまでデスクトップの世界に閉じていた、膨大なC++のコード資産と、それによって培われた高性能な計算能力を、世界中の数十億のユーザーがアクセスするWebプラットフォームへと解き放ちます。
本稿では、WebAssemblyの基本的な概念から始まり、Emscriptenを使った環境構築、JavaScriptとの基本的な連携、Embindによる高度なバインディング、仮想ファイルシステムによる永続化、そしてパフォーマンス最適化に至るまで、C++資産をWebで活用するための包括的な道のりを描きました。これらの知識は、単に古いコードを再利用するだけでなく、Webの表現力とネイティブの計算能力を融合させた、まったく新しいタイプのアプリケーションを創造するための礎となります。
WebAssemblyエコシステムは今も活発に進化を続けています。ガベージコレクションのサポート、ESモジュールとの統合、コンポーネントモデルによる言語間連携のさらなる進化など、未来は明るい展望に満ちています。C++開発者にとって、WebAssemblyは自らの技術と経験を新たなステージで活かすための、またとない機会を提供してくれるでしょう。ブラウザという制約を超え、C++コードが再び躍動する時代が、今まさに始まっています。
0 개의 댓글:
Post a Comment