C/C++でWASMをサクッとやりたい

少し前にWASMの開発をやることがあって(maplibre-gl-layersの話を参照)、そこでWASMのコードを書きました。 WASMと言っても、今だとRustを使うことが多いのかもしれませんが、私はまだRustをやっていない(Linux kernelが置き換わるまでには...)のと、 C/C++には慣れているので、C/C++でWASMコードを書きたいと思いました。

実際のところ、開発時間の問題であまり悠長にコードを書いているわけにも行かなかったので、今回は(も)諦めたという

それで、C/C++でWASMのコードを書く場合は、 Emscripten SDK を使うのが定番ということで、 そのまま採用することにしたのです。 ただ、C/C++で複雑なコードを書くわけではなく、今回は殆ど座標計算を大量に高速にやらせたいというのが目標だったので、 ぶっちゃけ、C/C++のエコシステム、具体的には外部ライブラリなどを使う可能性は殆どありませんでした。 せいぜい、std 名前空間のコレクションなどを使うぐらいでしょう。

そういうわけで、WASM開発でも恐らく定番となる CMake は使わず、殆ど直に生書きという選択肢を取りました。 そこまでシンプルなら、Makefileでも良いんじゃないのと言うつもりだったのです。

環境セットアップの問題

ただ、環境整備が面倒なんですよね。と言っても、Emscripten SDKを使う場合は、リポジトリをcloneしてきてセットアップスクリプトを実行すれば、 ホスト環境で必要なtoolchainをダウンロードしてきてくれるので、何が面倒なんだよ configure もしないじゃん、というツッコミはあるかもしれません:

git clone https://github.com/emscripten-core/emsdk
./emsdk install latest
./emsdk activate latest

仕事で使うので、環境再現性が高くないと色々辛く(例えばCI)、あまり手動セットアップで便利でもそれはそれで困ってしまいます。 もしこれをCIでやる場合は、Emscripten SDKをどこにcloneすれば良いのか(あるいはしなければならないのか)、 スクリプトがどのように環境を整備するのか、ということを把握していないと問題が起きそうです。

一度だけで済めば良いが...

そういうわけで、これをできるだけ簡潔な環境で簡潔な手順で再現性のある方法で実現しておかないと、色々なことをやっている自分には覚えておける記憶スペースがなく、 後で「これどうするんだっけ」みたいに激しく後悔することになります。

Makefileはまあシンプルですが、Emscriptenの全体的になんとかしてシンプルにしたい。楽にやりたい。 そこで、これらの問題をひっくるめて簡潔に完結させる何かが必要だと感じで、それなら作るかそこまで大変でもなさそうだし(フラグ)、と考えたやつです。

emsdk-env

で、作りました。 "emsdk-env" です。

TypeScriptの開発を始めてからネックだったことの一つは、ビルドプロセスのライフサイクルの標準的な環境が存在しないことです。 NPMのscript定義があるだろう、と言われそうで、やれなくもないですがその辺り弱すぎるので、今はViteをその目的に使っています。

Viteを前提にすれば、プラグインシステムでかなり柔軟にビルドプロセスに介入できます。 今回のような目的にぴったりです。

ただ、世間一般では、ViteはVueやReactのフロントエンド開発で使われているようで、本当は少し毛色が違うとは感じています。 特に自分はフロントエンド開発は片手間のような感じなので余計に。 しかし、他に良い選択肢も無いので、ちょっと無理がありますがすべてのTS開発でViteを採用しています。

つまり、Node.jsを使うようなプロジェクトでも、Viteを使ってビルドしています。 まだ色々課題はありますが、ElectronでもViteを使おうとしています。 これと全く同じ流れで、既に様々なViteプラグインを作っていますが、それらの解説はまだ今度。

話をemsdk-envに戻すと、これは、誤解を恐れずに言えば、Viteプラグイン化した"make"です:

  • Emscripten SDKの自動セットアップ・キャッシュ
  • Viteプラグインによる、HMR対応(但しC/C++コードは全体ビルドが行われます)
  • 並行ビルド対応
  • エクスポートシンボルの簡易指定が可能
  • 複数のターゲットWASMバイナリを生成可能
  • ディレクトリパス・コンパイルオプション・リンカオプションのカスタマイズが可能
  • アーカイブライブラリ(*.a)のビルドと参照が可能
  • NPMパッケージでWASMライブラリの配布と参照が可能

どうやってWASMコードを実行できるか

こんなリストよりも、ビルド定義をどう書くかの方が、一目瞭然でしょう。 まず、インストールして:

npm install -D emsdk-env

vite.config.ts にプラグイン構成を加えます:

// `vite.config.ts`
import { defineConfig } from 'vite';
 
// emsdk-envのViteプラグインを参照
import emsdkEnv from 'emsdk-env/vite';
 
export default defineConfig({
  plugins: [
    // プラグインとして追加
    emsdkEnv({
      // WASMローダーコードを生成
      generatedLoader: { enable: true },
      // ビルドターゲット
      targets: {
        // "add.wasm"を生成
        add: {
          // コンパイルオプション
          options: ['-O3', '-std=c99'],
          // リンクオプション
          linkOptions: ['--no-entry'],
          // リンクディレクティブ
          linkDirectives: { STANDALONE_WASM: 1 },
          // エクスポートシンボル
          exports: ['_add'],
        },
      },
    }),
  ],
});

targets の辺りが、だいたい Makefileのように機能しますが、とにかく楽に最小限の定義で使えるように工夫してあります。 とは言ってもWASMでEmscripten SDKを使う場合のお約束、 STANDALONE_WASM--no-entry のような最低限のオプション指定は必要です。

それでも、そこさえ押さえれば、あとはJS世界からWASMで定義した関数が見えるように、 exports にシンボル名を書くだけで行けるようになっています。 以下のようなプロジェクトディレクトリ配置を行って:

project/
├── package.json
├── vite.config.ts
├── src/
│   ├── generated/
│   │   └── wasm-loader.ts   // (自動生成)
│   └── wasm/
│       └── add.wasm         // (ビルドされたWASMバイナリ)
└── wasm/
    └── add.c

Cのコードを書くだけです(もちろんC++でもOK):

int add(int a, int b) {
  return a + b;
}

WASM開発を行う場合は、もう一つ、どうやってTSからWASMに定義した関数を呼び出すのか、という問題があります。 これも毎回面倒なスタブ実装を書く必要があるのですが、emsdk-envでヘルパーコードを生成できるようにしてあります(generatedLoader)。 これが有効なら wasm-loader.ts ファイルが生成されるので、あとはこれを使うだけです:

import { loadAddWasm } from './generated/wasm-loader';
 
// WASMエクスポート関数の定義 (手動で定義が必要です)
interface AddExports {
  add?: (a: number, b: number) => number;
}
 
// WASMバイナリをロードして使用可能にする
const wasm = await loadAddWasm<AddExports>();
 
// `add()`関数を取得
const add = wasm.exports.add!;
 
// 関数を実行
const result = add(1, 2);

他にも様々なオプションや、ビルド戦略に使用できる柔軟な定義が出来るので、興味があれば READMEを参照してください (詳しく記載しています)。

重要な機能

重要な機能の説明を忘れるところだった... emsdk-envはViteプラグインなので、HMRに対応しているんですよ (!!!)

つまり、npm run dev などでVite devサーバーを起動しておき、C/C++のソースコードを編集して保存すると、ブラウザプレビューに反映されますよ。 もちろん、TS/JSのように、ページの部分更新まで含んだ真のHMRというわけには行かず、C/C++コードはフルビルドが発生します。 それでも、これは非常に開発体験が良いです。

一応、平行ビルドを行うようにして、あまりTS/JS側のHMRと見劣りしないように頑張ってはいますが、強いて挙げるならここは今後の課題です。

それともう一つ、emsdk-env向けのディレクトリ構成を守ってNPMパッケージを生成すれば、WASMパッケージを作れます (!!!!!!)

つまり、あなたのWASMライブラリをNPMで簡単に取り込んで使えるようになるのです。 例えば、作ったライブラリのヘッダファイルとアーカイブファイルがあれば、それらを取り込んだNPMパッケージを作り、 使用者はNPMパッケージをインストールして:

export default defineConfig({
  plugins: [
    emsdkEnv({
      // "wasm-calc-lib"パッケージのライブラリを使用する
      imports: ['wasm-calc-lib'],
      targets: {
        // "offload.wasm"
        offload: {
          // "wasm-calc-lib"パッケージの"libcalc.a"を参照
          linkOptions: ['-lcalc'],
 
          //  :
          //  :
        },
      },
    }),
  ],
});

のようにするだけで、そのライブラリを使えるようになります。 これは、私がVS2013の頃に、ライブラリのパッケージングで考えていた事(WASMの話ではありませんよ! その頃にはまだWASMは無いです)で、 今になって(環境は違うけど)やっと具現化したよなぁと言う、ちょっと胸熱な機能です。

その他

ヘルパーコードは、型定義までは踏み込んでいないため、エクスポート関数の定義は手で書く必要があります。 ここはTypescript APIを使用すれば自動化できることは分かっているのですが、ビルド時間に影響があるので現在はそこまでやっていません。

TS APIは6.0でも(高速化について)消極的のように感じたので、サポートするかどうかはまだ未定です。

まとめ

emsdk-envはViteプラグインで、Emscripten SDKの自動セットアップやビルド管理、HMR対応を行います。 CMake を使うような大規模開発ではなく、もっと小規模で高速開発を行いたいのなら、良い選択肢だと思います。

maplibre-gl-layers や、 massive-sprites で、実際に使用しているので、参考にしてください。