C または C++ コードとしてのみ利用可能なライブラリを使用したい場合があります。従来であれば、ここで諦めることになります。しかし、Emscripten と WebAssembly(Wasm)の登場により、状況は変わりました。
ツールチェーン
私は、既存の C コードを Wasm にコンパイルする方法を学ぶことを目標にしました。LLVM の Wasm バックエンドに関する話題がいくつかあったので、詳しく調べてみました。この方法でシンプルなプログラムをコンパイルすることはできますが、C の標準ライブラリを使用したり、複数のファイルをコンパイルしたりしようとすると、問題が発生する可能性があります。このことから、私は重要な教訓を得ました。
Emscripten は以前は C-to-asm.js コンパイラでしたが、その後 Wasm をターゲットとするように成熟し、内部で公式の LLVM バックエンドに切り替えるプロセスにあります。Emscripten は、C の標準ライブラリの Wasm 互換実装も提供します。Emscripten を使用する。多くの隠れた作業を担い、ファイル システムをエミュレートし、メモリ管理を提供し、OpenGL を WebGL でラップします。これらは、開発者が自分で経験する必要のない多くのことです。
肥大化を心配する必要があるように聞こえるかもしれませんが(私も心配しました)、Emscripten コンパイラは不要なものをすべて削除します。私のテストでは、生成された Wasm モジュールは、含まれるロジックに適したサイズであり、Emscripten チームと WebAssembly チームは、今後さらにサイズを小さくするよう取り組んでいます。
Emscripten を入手するには、ウェブサイトの手順に沿って操作するか、Homebrew を使用します。私のように Docker 化されたコマンドが好きで、WebAssembly を試すためだけにシステムにインストールしたくない場合は、代わりに使用できる、よくメンテナンスされた Docker イメージがあります。
$ docker pull trzeci/emscripten
$ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>
シンプルなものをコンパイルする
nth 番目のフィボナッチ数を計算する C 言語の関数を記述する、ほぼ標準的な例を見てみましょう。
#include <emscripten.h>
EMSCRIPTEN_KEEPALIVE
int fib(int n) {
if(n <= 0){
return 0;
}
int i, t, a = 0, b = 1;
for (i = 1; i < n; i++) {
t = a + b;
a = b;
b = t;
}
return b;
}
C 言語を理解している場合、関数自体はそれほど驚くべきものではありません。C 言語を知らなくても、JavaScript を知っていれば、ここで何が行われているかを理解できるはずです。
emscripten.h は、Emscripten が提供するヘッダー ファイルです。EMSCRIPTEN_KEEPALIVE マクロにアクセスするためにのみ必要ですが、他にも多くの機能を提供します。このマクロは、未使用のように見える関数でも削除しないようコンパイラに指示します。このマクロを省略すると、コンパイラは関数を最適化します。結局のところ、誰も使用していないからです。
これらをすべて fib.c というファイルに保存しましょう。.wasm ファイルに変換するには、Emscripten のコンパイラ コマンド emcc を使用する必要があります。
$ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c
このコマンドを詳しく見てみましょう。emcc は Emscripten のコンパイラです。fib.c は C ファイルです。ここまでは順調です。-s WASM=1 は、asm.js ファイルではなく Wasm ファイルを提供するよう Emscripten に指示します。-s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' は、JavaScript ファイルで cwrap() 関数を使用できるようにコンパイラに指示します。この関数については後ほど詳しく説明します。-O3 は、積極的に最適化するようコンパイラに指示します。ビルド時間を短縮するために、より小さい数を選択することもできますが、コンパイラが未使用のコードを削除しない可能性があるため、結果のバンドルが大きくなります。
コマンドを実行すると、a.out.js という JavaScript ファイルと a.out.wasm という WebAssembly ファイルが作成されます。Wasm ファイル(または「モジュール」)には、コンパイルされた C コードが含まれており、かなり小さくなっているはずです。JavaScript ファイルは、Wasm モジュールの読み込みと初期化、より優れた API の提供を行います。必要に応じて、スタック、ヒープ、および C コードを記述するときに通常はオペレーティング システムによって提供されるその他の機能の設定も行います。そのため、JavaScript ファイルは少し大きくなり、19 KB(gzip 圧縮で約 5 KB)になります。
シンプルな処理を実行する
モジュールを読み込んで実行する最も簡単な方法は、生成された JavaScript ファイルを使用することです。このファイルを読み込むと、Module グローバルを使用できるようになります。cwrap を使用して、パラメータを C に適したものに変換し、ラップされた関数を呼び出す JavaScript ネイティブ関数を作成します。cwrap は、関数名、戻り値の型、引数の型をこの順序で引数として受け取ります。
<script src="a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
const fib = Module.cwrap('fib', 'number', ['number']);
console.log(fib(12));
};
</script>
このコードを実行すると、コンソールに 12 番目のフィボナッチ数である「144」が表示されます。
究極の目標: C ライブラリのコンパイル
これまで、作成した C コードは Wasm を念頭に置いて作成されていました。しかし、WebAssembly の主なユースケースは、C ライブラリの既存のエコシステムを利用して、デベロッパーがウェブ上でそれらを使用できるようにすることです。これらのライブラリは、多くの場合、C の標準ライブラリ、オペレーティング システム、ファイル システムなどに依存しています。Emscripten は、いくつかの制限はあるものの、これらの機能のほとんどを提供します。
最初の目標である、WebP のエンコーダを Wasm にコンパイルする作業に戻りましょう。WebP コーデックのソースは C 言語で記述されており、GitHub で入手できます。また、詳細な API ドキュメントも用意されています。これはかなり良い出発点です。
$ git clone https://github.com/webmproject/libwebp
まずは、webp.c という C ファイルを記述して、encode.h の WebPGetEncoderVersion() を JavaScript に公開してみましょう。
#include "emscripten.h"
#include "src/webp/encode.h"
EMSCRIPTEN_KEEPALIVE
int version() {
return WebPGetEncoderVersion();
}
この関数を呼び出すためにパラメータや複雑なデータ構造は必要ないため、libwebp のソースコードをコンパイルできるかどうかをテストするのに適したシンプルなプログラムです。
このプログラムをコンパイルするには、-I フラグを使用して libwebp のヘッダー ファイルの場所をコンパイラに指示し、必要な libwebp のすべての C ファイルを渡す必要があります。正直に言うと、見つかった C ファイルをすべてコンパイラに渡し、不要なものはすべてコンパイラに削除してもらいました。これは素晴らしい効果を発揮したようです。
$ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
-I libwebp \
webp.c \
libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c
あとは、HTML と JavaScript を使用して、新しいモジュールを読み込むだけです。
<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = async (_) => {
const api = {
version: Module.cwrap('version', 'number', []),
};
console.log(api.version());
};
</script>
出力に修正バージョン番号が表示されます。

JavaScript から Wasm に画像を取得する
エンコーダのバージョン番号を取得するのもよいですが、実際の画像をエンコードする方がより印象的です。では、そうしましょう。
最初に解決すべき問題は、画像を Wasm ランドにどのように取り込むかです。libwebp のエンコード API を見ると、RGB、RGBA、BGR、BGRA のバイト配列が想定されています。幸い、Canvas API には getImageData() があり、RGBA の画像データを含む Uint8ClampedArray が提供されます。
async function loadImage(src) {
// Load image
const imgBlob = await fetch(src).then((resp) => resp.blob());
const img = await createImageBitmap(imgBlob);
// Make canvas same size as image
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
// Draw image onto canvas
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
return ctx.getImageData(0, 0, img.width, img.height);
}
あとは、JavaScript ランドから Wasm ランドにデータをコピーする「だけ」です。そのためには、2 つの追加関数を公開する必要があります。1 つは Wasm ランド内のイメージにメモリを割り当てる関数、もう 1 つはメモリを解放する関数です。
EMSCRIPTEN_KEEPALIVE
uint8_t* create_buffer(int width, int height) {
return malloc(width * height * 4 * sizeof(uint8_t));
}
EMSCRIPTEN_KEEPALIVE
void destroy_buffer(uint8_t* p) {
free(p);
}
create_buffer は、RGBA 画像用のバッファを割り当てます。したがって、1 ピクセルあたり 4 バイトです。malloc() によって返されるポインタは、そのバッファの最初のメモリセルのアドレスです。ポインタが JavaScript ランドに戻されると、単なる数値として扱われます。cwrap を使用して関数を JavaScript に公開すると、その番号を使用してバッファの開始位置を特定し、画像データをコピーできます。
const api = {
version: Module.cwrap('version', 'number', []),
create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
const image = await loadImage('/image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);
グランド フィナーレ: 画像をエンコードする
これで、Wasm ランドで画像を使用できるようになりました。WebP エンコーダを呼び出して処理を行います。WebP のドキュメントを見ると、WebPEncodeRGBA が最適のようです。この関数は、入力画像とそのディメンションへのポインタと、0 ~ 100 の品質オプションを受け取ります。また、出力バッファも割り当てられます。WebP 画像の処理が完了したら、WebPFree() を使用してこのバッファを解放する必要があります。
エンコード オペレーションの結果は、出力バッファとその長さです。C の関数では、配列を戻り値の型にできないため(メモリを動的に割り当てない限り)、静的なグローバル配列を使用しました。クリーンな C ではありませんが(実際には、Wasm ポインタが 32 ビット幅であるという事実に依存しています)、シンプルに保つために、これは適切なショートカットだと思います。
int result[2];
EMSCRIPTEN_KEEPALIVE
void encode(uint8_t* img_in, int width, int height, float quality) {
uint8_t* img_out;
size_t size;
size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);
result[0] = (int)img_out;
result[1] = size;
}
EMSCRIPTEN_KEEPALIVE
void free_result(uint8_t* result) {
WebPFree(result);
}
EMSCRIPTEN_KEEPALIVE
int get_result_pointer() {
return result[0];
}
EMSCRIPTEN_KEEPALIVE
int get_result_size() {
return result[1];
}
これで、エンコード関数を呼び出し、ポインタと画像サイズを取得して、独自の JavaScript ランドバッファに配置し、プロセスで割り当てたすべての Wasm ランドバッファを解放できます。
api.encode(p, image.width, image.height, 100);
const resultPointer = api.get_result_pointer();
const resultSize = api.get_result_size();
const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
const result = new Uint8Array(resultView);
api.free_result(resultPointer);
画像のサイズによっては、Wasm が入力画像と出力画像の両方を格納するのに十分なメモリを確保できないというエラーが発生することがあります。

幸いなことに、この問題の解決策はエラー メッセージに記載されています。コンパイル コマンドに -s ALLOW_MEMORY_GROWTH=1 を追加するだけです。
このように、WebP エンコーダをコンパイルし、JPEG 画像を WebP にコード変換しました。動作を証明するために、結果バッファを blob に変換し、<img> 要素で使用します。
const blob = new Blob([result], { type: 'image/webp' });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);

まとめ
C ライブラリをブラウザで動作させるのは簡単ではありませんが、プロセス全体とデータフローの仕組みを理解すれば、簡単になり、驚くべき結果が得られます。
WebAssembly は、処理、数値計算、ゲームなど、ウェブ上で多くの新しい可能性を切り開きます。Wasm は、あらゆるものに適用できる万能なソリューションではありませんが、ボトルネックに直面したときに、非常に役立つツールとなります。
ボーナス コンテンツ: シンプルなものを複雑な方法で実行する
生成された JavaScript ファイルを回避したい場合は、回避できる可能性があります。フィボナッチの例に戻りましょう。これを読み込んで実行するには、次のようにします。
<!DOCTYPE html>
<script>
(async function () {
const imports = {
env: {
memory: new WebAssembly.Memory({ initial: 1 }),
STACKTOP: 0,
},
};
const { instance } = await WebAssembly.instantiateStreaming(
fetch('/a.out.wasm'),
imports,
);
console.log(instance.exports._fib(12));
})();
</script>
Emscripten によって作成された WebAssembly モジュールは、メモリを提供しない限り、動作するメモリがありません。Wasm モジュールに何かを提供するには、imports オブジェクト(instantiateStreaming 関数の 2 番目のパラメータ)を使用します。Wasm モジュールは、imports オブジェクト内のすべてのものにアクセスできますが、それ以外のものにはアクセスできません。慣例として、Emscripting でコンパイルされたモジュールは、読み込み元の JavaScript 環境にいくつかのことを期待します。
- まず、
env.memoryがあります。Wasm モジュールは、いわば外部の世界を認識していないため、動作するためにメモリを取得する必要があります。「WebAssembly.Memory」と入力します。これは、線形メモリの(必要に応じて拡張可能な)部分を表します。サイズ設定パラメータは「WebAssembly ページの単位」です。つまり、上記のコードは 1 ページのメモリを割り当て、各ページのサイズは 64 KiB です。maximumオプションを指定しない場合、メモリは理論上は無制限に増加します(Chrome には現在 2 GB のハードリミットがあります)。ほとんどの WebAssembly モジュールでは、最大値を設定する必要はありません。 env.STACKTOPは、スタックの拡張を開始する場所を定義します。スタックは、関数呼び出しを行い、ローカル変数にメモリを割り当てるために必要です。この小さなフィボナッチ プログラムでは動的メモリ管理のトリックは行わないため、メモリ全体をスタックとして使用できます。したがって、STACKTOP = 0となります。