この設定に WebAssembly を統合するにはどうすればよいですか?この記事では、C/C++ と Emscripten を例に、この問題を解決します。
WebAssembly(wasm)は、パフォーマンス プリミティブまたは既存の C++ コードベースをウェブで実行する方法としてよく使われます。squoosh.app では、wasm には少なくとも 3 つ目の視点があることを示したいと考えました。それは、他のプログラミング言語の巨大なエコシステムを活用することです。Emscripten を使用すると、C/C++ コードを使用できます。Rust には wasm サポートが組み込まれており、Go チームも取り組んでいます。他の言語にも順次対応していく予定です。
このようなシナリオでは、wasm はアプリの中心ではなく、パズルのピース、つまり別のモジュールです。アプリにはすでに JavaScript、CSS、画像アセット、ウェブ中心のビルドシステムがあり、React などのフレームワークも使用しているかもしれません。この設定に WebAssembly を統合するにはどうすればよいですか?この記事では、C/C++ と Emscripten を例として、この問題を解決します。
Docker
Emscripten を使用する際に Docker が非常に役立つことがわかりました。C/C++ ライブラリは、ビルドされたオペレーティング システムで動作するように記述されることがよくあります。一貫性のある環境は非常に便利です。Docker を使用すると、Emscripten で動作するようにすでに設定され、すべてのツールと依存関係がインストールされた仮想化 Linux システムを取得できます。不足しているものがあれば、自分のマシンや他のプロジェクトに影響することを気にせずにインストールできます。問題が発生した場合は、コンテナを破棄して最初からやり直します。1 回動作すれば、その後も動作し、同じ結果が得られることが保証されます。
Docker レジストリには、私が頻繁に使用している trzeci による Emscripten イメージがあります。
npm との統合
ほとんどの場合、ウェブ プロジェクトのエントリ ポイントは npm の package.json
です。通常、ほとんどのプロジェクトは npm install &&
npm run build
でビルドできます。
一般に、Emscripten によって生成されたビルド アーティファクト(.js
ファイルと .wasm
ファイル)は、単なる JavaScript モジュールおよび単なるアセットとして扱われるべきです。JavaScript ファイルは webpack や rollup などのバンドラーで処理できます。wasm ファイルは、画像などの他の大きなバイナリ アセットと同様に扱う必要があります。
そのため、Emscripten ビルド アーティファクトは、「通常の」ビルド プロセスが開始される前にビルドする必要があります。
{
"name": "my-worldchanging-project",
"scripts": {
"build:emscripten": "docker run --rm -v $(pwd):/src trzeci/emscripten
./build.sh",
"build:app": "<the old build command>",
"build": "npm run build:emscripten && npm run build:app",
// ...
},
// ...
}
新しい build:emscripten
タスクは Emscripten を直接呼び出すことができますが、前述のように、ビルド環境の一貫性を確保するために Docker を使用することをおすすめします。
docker run ... trzeci/emscripten ./build.sh
は、trzeci/emscripten
イメージを使用して新しいコンテナを起動し、./build.sh
コマンドを実行するように Docker に指示します。build.sh
は、次に作成するシェル スクリプトです。--rm
は、実行が完了した後にコンテナを削除するように Docker に指示します。これにより、古いマシンイメージのコレクションが時間の経過とともに蓄積されることはありません。-v $(pwd):/src
は、Docker に現行ディレクトリ($(pwd)
)をコンテナ内の /src
にミラーリングすることを指示します。コンテナ内の /src
ディレクトリ内のファイルに加えた変更は、実際のプロジェクトに反映されます。これらのミラーリングされたディレクトリは「バインド マウント」と呼ばれます。
build.sh
を見てみましょう。
#!/bin/bash
set -e
export OPTIMIZE="-Os"
export LDFLAGS="${OPTIMIZE}"
export CFLAGS="${OPTIMIZE}"
export CXXFLAGS="${OPTIMIZE}"
echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
# Compile C/C++ code
emcc \
${OPTIMIZE} \
--bind \
-s STRICT=1 \
-s ALLOW_MEMORY_GROWTH=1 \
-s MALLOC=emmalloc \
-s MODULARIZE=1 \
-s EXPORT_ES6=1 \
-o ./my-module.js \
src/my-module.cpp
# Create output folder
mkdir -p dist
# Move artifacts
mv my-module.{js,wasm} dist
)
echo "============================================="
echo "Compiling wasm bindings done"
echo "============================================="
ここでは多くのことを分析できます。
set -e
はシェルを「fail fast」モードにします。スクリプト内のコマンドがエラーを返すと、スクリプト全体が直ちに中止されます。スクリプトの最後の出力は常に成功メッセージか、ビルドの失敗の原因となったエラーになるため、これは非常に役立ちます。
export
ステートメントでは、いくつかの環境変数の値を定義します。これらを使用すると、追加のコマンドライン パラメータを C コンパイラ(CFLAGS
)、C++ コンパイラ(CXXFLAGS
)、リンカー(LDFLAGS
)に渡すことができます。これらはすべて OPTIMIZE
を介してオプティマイザー設定を受け取り、すべてが同じ方法で最適化されるようにします。OPTIMIZE
変数には、次の 2 つの値を使用できます。
-O0
: 最適化を行いません。不要なコードは削除されず、Emscripten は出力する JavaScript コードを縮小化しません。デバッグに便利です。-O3
: パフォーマンスを重視して積極的に最適化します。-Os
: パフォーマンスを重視して積極的に最適化し、サイズを二次的な基準として使用します。-Oz
: サイズを積極的に最適化し、必要に応じてパフォーマンスを犠牲にします。
ウェブの場合、ほとんどは -Os
をおすすめします。
emcc
コマンドには、独自のオプションが多数あります。emcc は「GCC や clang などのコンパイラのドロップイン代替」であることに注意してください。そのため、GCC で使用できるフラグは、emcc でも実装されている可能性が高くなります。-s
フラグは、Emscripten を具体的に構成できるという点で特殊です。使用可能なすべてのオプションは Emscripten の settings.js
にありますが、このファイルは非常に大きくなる可能性があります。ウェブ デベロッパーにとって特に重要な Emscripten フラグを以下に示します。
--bind
は embind を有効にします。-s STRICT=1
は、非推奨のビルド オプションのサポートをすべて終了します。これにより、コードが前方互換性のある方法でビルドされます。-s ALLOW_MEMORY_GROWTH=1
を使用すると、必要に応じてメモリを自動的に増やすことができます。この記事の執筆時点では、Emscripten は最初に 16 MB のメモリを割り当てます。コードがメモリのチャンクを割り当てる際に、このオプションは、メモリが枯渇したときにこれらのオペレーションで wasm モジュール全体を失敗させるか、グルーコードが割り当てに対応するために合計メモリを拡張することを許可するかを決定します。-s MALLOC=...
は、使用するmalloc()
実装を選択します。emmalloc
は、Emscripten 専用の小さくて高速なmalloc()
実装です。代替は、完全なmalloc()
実装であるdlmalloc
です。多数の小さなオブジェクトを頻繁に割り当てる場合や、スレッドを使用する場合は、dlmalloc
に切り替える必要があります。-s EXPORT_ES6=1
は、JavaScript コードを任意のバンドラーで動作するデフォルトのエクスポートを含む ES6 モジュールに変換します。-s MODULARIZE=1
の設定も必要です。
次のフラグは必ずしも必要ではなく、デバッグ目的でのみ役立ちます。
-s FILESYSTEM=0
は Emscripten に関連するフラグで、C/C++ コードがファイル システム オペレーションを使用するときに、ファイル システムをエミュレートする機能です。コンパイルするコードを分析して、ファイル システム エミュレーションをグルーコードに含めるかどうかを決定します。ただし、この分析が間違っている場合もあり、必要のないファイル システム エミュレーションのために 70 kB というかなり大きな追加のグルーコードを支払うことになります。-s FILESYSTEM=0
を使用すると、Emscripten にこのコードを含めないように強制できます。-g4
を指定すると、Emscripten は.wasm
にデバッグ情報を含め、wasm モジュールのソースマップ ファイルも出力します。Emscripten を使用したデバッグの詳細については、デバッグのセクションをご覧ください。
このように、この設定をテストするために、小さな my-module.cpp
を作成しましょう。
#include <emscripten/bind.h>
using namespace emscripten;
int say_hello() {
printf("Hello from your wasm module\n");
return 0;
}
EMSCRIPTEN_BINDINGS(my_module) {
function("sayHello", &say_hello);
}
index.html
:
<!doctype html>
<title>Emscripten + npm example</title>
Open the console to see the output from the wasm module.
<script type="module">
import wasmModule from "./my-module.js";
const instance = wasmModule({
onRuntimeInitialized() {
instance.sayHello();
}
});
</script>
(すべてのファイルを含む gist はこちらです)。
すべてをビルドするには、次のコマンドを実行します。
$ npm install
$ npm run build
$ npm run serve
localhost:8080 に移動すると、DevTools コンソールに次の出力が表示されます。

C/C++ コードを依存関係として追加する
ウェブアプリ用の C/C++ ライブラリをビルドする場合は、そのコードをプロジェクトの一部にする必要があります。コードはプロジェクトのリポジトリに手動で追加することも、npm を使用してこのような依存関係を管理することもできます。ウェブアプリで libvpx を使用するとします。libvpx は、.webm
ファイルで使用されるコーデックである VP8 で画像をエンコードするための C++ ライブラリです。ただし、libvpx は npm になく、package.json
もないため、npm を使用して直接インストールすることはできません。
この問題を解決するために、napa があります。napa を使用すると、任意の git リポジトリ URL を依存関係として node_modules
フォルダにインストールできます。
napa を依存関係としてインストールします。
$ npm install --save napa
napa
をインストール スクリプトとして実行してください。
{
// ...
"scripts": {
"install": "napa",
// ...
},
"napa": {
"libvpx": "git+https://github.com/webmproject/libvpx"
}
// ...
}
npm install
を実行すると、napa は libvpx GitHub リポジトリのクローンを libvpx
という名前で node_modules
に作成します。
ビルド スクリプトを拡張して libvpx をビルドできるようになりました。libvpx は configure
と make
を使用してビルドされます。幸いなことに、Emscripten を使用すると、configure
と make
で Emscripten のコンパイラを使用できます。この目的のために、ラッパー コマンド emconfigure
と emmake
があります。
# ... above is unchanged ...
echo "============================================="
echo "Compiling libvpx"
echo "============================================="
(
rm -rf build-vpx || true
mkdir build-vpx
cd build-vpx
emconfigure ../node_modules/libvpx/configure \
--target=generic-gnu
emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="
echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
# ... below is unchanged ...
C/C++ ライブラリは、ライブラリが公開するデータ構造、クラス、定数などを定義するヘッダー(従来は .h
または .hpp
ファイル)と、実際のライブラリ(従来は .so
または .a
ファイル)の 2 つの部分に分割されます。コードでライブラリの VPX_CODEC_ABI_VERSION
定数を使用するには、#include
ステートメントを使用してライブラリのヘッダー ファイルを含める必要があります。
#include "vpxenc.h"
#include <emscripten/bind.h>
int say_hello() {
printf("Hello from your wasm module with libvpx %d\n", VPX_CODEC_ABI_VERSION);
return 0;
}
問題は、コンパイラが vpxenc.h
を探す場所を認識していないことです。これは -I
フラグの目的です。コンパイラにヘッダー ファイルをチェックするディレクトリを指示します。また、コンパイラに実際のライブラリ ファイルも指定する必要があります。
# ... above is unchanged ...
echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
# Compile C/C++ code
emcc \
${OPTIMIZE} \
--bind \
-s STRICT=1 \
-s ALLOW_MEMORY_GROWTH=1 \
-s ASSERTIONS=0 \
-s MALLOC=emmalloc \
-s MODULARIZE=1 \
-s EXPORT_ES6=1 \
-o ./my-module.js \
-I ./node_modules/libvpx \
src/my-module.cpp \
build-vpx/libvpx.a
# ... below is unchanged ...
ここで npm run build
を実行すると、プロセスが新しい .js
と新しい .wasm
ファイルをビルドし、デモページが実際に定数を出力することがわかります。

また、ビルドプロセスに時間がかかることにも気づくでしょう。ビルド時間が長くなる理由はさまざまです。libvpx の場合、ソースファイルが変更されていない場合でも、ビルド コマンドを実行するたびに VP8 と VP9 の両方のエンコーダとデコーダをコンパイルするため、時間がかかります。my-module.cpp
に小さな変更を加えただけでも、ビルドに長い時間がかかります。libvpx のビルド アーティファクトは、一度ビルドされたら保持しておくと非常に便利です。
この方法の 1 つは、環境変数を使用することです。
# ... above is unchanged ...
eval $@
echo "============================================="
echo "Compiling libvpx"
echo "============================================="
test -n "$SKIP_LIBVPX" || (
rm -rf build-vpx || true
mkdir build-vpx
cd build-vpx
emconfigure ../node_modules/libvpx/configure \
--target=generic-gnu
emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="
# ... below is unchanged ...
(すべてのファイルを含む gist をご覧ください)。
eval
コマンドを使用すると、ビルド スクリプトにパラメータを渡して環境変数を設定できます。$SKIP_LIBVPX
が設定されている場合(任意の値に)、test
コマンドは libvpx のビルドをスキップします。
これで、モジュールをコンパイルできますが、libvpx の再構築はスキップされます。
$ npm run build:emscripten -- SKIP_LIBVPX=1
ビルド環境をカスタマイズする
ライブラリによっては、ビルドに追加のツールが必要になることがあります。Docker イメージで提供されるビルド環境にこれらの依存関係がない場合は、自分で追加する必要があります。たとえば、doxygen を使用して libvpx のドキュメントもビルドするとします。Doxygen は Docker コンテナ内では使用できませんが、apt
を使用してインストールできます。
build.sh
でこれを行うと、ライブラリをビルドするたびに doxygen を再ダウンロードして再インストールすることになります。これは無駄であるだけでなく、オフラインでプロジェクトに取り組むこともできなくなります。
ここでは、独自の Docker イメージをビルドすることをおすすめします。Docker イメージは、ビルドステップを記述する Dockerfile
を作成することでビルドされます。Dockerfile は非常に強力で、多くのコマンドがありますが、ほとんどの場合、FROM
、RUN
、ADD
のみを使用すれば十分です。この例の場合は、次のようになります。
FROM trzeci/emscripten
RUN apt-get update && \
apt-get install -qqy doxygen
FROM
を使用すると、開始点として使用する Docker イメージを宣言できます。ベースとして trzeci/emscripten
(これまで使用してきたイメージ)を選択しました。RUN
を使用すると、コンテナ内でシェル コマンドを実行するように Docker に指示します。これらのコマンドによってコンテナに加えられた変更は、Docker イメージの一部になります。build.sh
を実行する前に Docker イメージがビルドされ、使用可能になっていることを確認するには、package.json
を少し調整する必要があります。
{
// ...
"scripts": {
"build:dockerimage": "docker image inspect -f '.' mydockerimage || docker build -t mydockerimage .",
"build:emscripten": "docker run --rm -v $(pwd):/src mydockerimage ./build.sh",
"build": "npm run build:dockerimage && npm run build:emscripten && npm run build:app",
// ...
},
// ...
}
(すべてのファイルを含む gist をご覧ください)。
これにより、Docker イメージがビルドされます(まだビルドされていない場合のみ)。その後は以前と同じように実行されますが、ビルド環境で doxygen
コマンドが使用可能になり、libvpx のドキュメントもビルドされるようになります。
まとめ
C/C++ コードと npm が自然に適合しないのは当然ですが、追加のツールと Docker が提供する分離機能を使用すると、かなり快適に動作させることができます。この設定はすべてのプロジェクトで機能するわけではありませんが、ニーズに合わせて調整できる適切な出発点となります。改善点がありましたら、共有してください。
付録: Docker イメージレイヤの活用
別の解決策として、これらの問題の多くを Docker と Docker のスマートなキャッシュ保存アプローチでカプセル化する方法があります。Docker は Dockerfile をステップごとに実行し、各ステップの結果に独自のイメージを割り当てます。このような中間画像は「レイヤ」と呼ばれることがよくあります。Dockerfile のコマンドが変更されていない場合、Dockerfile を再ビルドしても、Docker はそのステップを再実行しません。代わりに、イメージが最後にビルドされたときのレイヤが再利用されます。
以前は、アプリをビルドするたびに libvpx を再ビルドしないように、ある程度の作業が必要でした。代わりに、libvpx のビルド手順を build.sh
から Dockerfile
に移動して、Docker のキャッシュ メカニズムを利用できます。
FROM trzeci/emscripten
RUN apt-get update && \
apt-get install -qqy doxygen git && \
mkdir -p /opt/libvpx/build && \
git clone https://github.com/webmproject/libvpx /opt/libvpx/src
RUN cd /opt/libvpx/build && \
emconfigure ../src/configure --target=generic-gnu && \
emmake make
(すべてのファイルを含む gist をご覧ください)。
docker build
の実行時にバインド マウントがないため、git を手動でインストールして libvpx を複製する必要があります。副作用として、napa は不要になりました。