TL;DR
webpack loader の emcc-loader で複数の C/C++ ファイルから 1 つの wasm (と補助の js) を書き出し、Typescript でその WebAssembly を扱うお話です。
はじめに
こんにちは。お仕事を探し中の狐崎めずもなです。
現在、趣味でブラウザでも Android/iOS native でも動かせるゲームを C++ で開発しています。
今回は、その足がかりとしてブラウザ上で C/C++ の関数を呼び出す所までをまとめました。
開発環境
- macOS 10.12.6
- Emscripten 1.37.34
- Chrome 64
- Firefox 58
環境構築
emcc-loader を使うには、先に Emscripten をインストールする必要があります。
Emscripten はインストール用の SDK がありますが、
自分は Homebrew で管理したかったので brew
で Emscripten を入れることにしました。
この方法の場合、インストールしたあとに少し設定の変更が必要です。
# Homebrew で binaryen と emscripten のインストール
brew install binaryen emscripten
# emscripten の初期化
emcc
# 好きなテキストエディタで ${HOME}/.emscripten を開きます。
# そして、以下の 2 行行頭に # を付けてコメントアウトし、
#
# LLVM_ROOT = os.path.expanduser(os.getenv('LLVM', '/usr/local/opt/llvm/bin')) # directory
# BINARYEN_ROOT = os.path.expanduser(os.getenv('BINARYEN', '')) # if not set, we will use it from ports
#
# それらの行の直後に LLVM_ROOT の設定を次の様に記述してください。
#
# LLVM_ROOT = '/usr/local/opt/emscripten/libexec/llvm/bin'
#
${your_editor} ~/.emscripten
# emscripten の動作チェック
# エラーが出ていなさそうであれば大丈夫。
emcc -v
webpack 用のプロジェクトの作成
Webpack や Typescript 用の詳細な環境構築は省きます。
以下のページが環境構築方法の参考になると思います。
- はじめてのWebpack の「webpack-dev-serverをインストール」あたりまで
- TypeScript+webpackでRxJSを試す の「npm コマンドの設定」あたりまで
今回は、下記のコマンドと webpack.config.js で
最小限の Typescript プロジェクトの枠組みを作りました。
mkdir testProject
cd testProject/
npm init # 色々聞かれるが、Enter で飛ばしても良い。最後だけ yes を入力して Enter
npm install --save webpack typescript awesome-typescript-loader
npm install --save html-webpack-plugin webpack-dev-server
./node_modules/.bin/tsc --init --target es2017 --module es2015
mkdir src dist temp
touch webpack.config.js
touch src/index.ts
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
context: `${__dirname}/src`,
entry: {
index: './index.ts'
}
output: {
path: `${__dirname}/dist`,
filename: '[name].js'
},
module: {
rules: [
{
test: /\.ts$/,
use: 'awesome-typescript-loader'
}
]
},
plugins: [ new HtmlWebpackPlugin() ],
externals: {
fs: "empty"
}
};
(webpack.config.js のパス記述は本当は path.join
を使ったほうが良いとは思いますが、今回はチュートリアルなので割愛します。)
今回のプロジェクトでは、このようなディレクトリ構成になります。
testProject/
# ソースファイルディレクトリ (webpack.config.js の context)
src/
# エントリポイント (webpack.config.js の entry)
index.ts
# 今回使用する C ファイル
sayHello.c
printUA.c
# C/C++ ファイルの一覧テキスト
mywasm.clist
# wasm.clist で使える関数の Typescript 型定義 (.clist と同名のファイルにすること)
mywasm.d.ts
# webpack の成果物 (webpack.config.js の output.path)
dist/
# emcc-loader の中間出力ファイル置き場 (emcc-loader の options.buildDir)
temp/
# npm install でインストールされたモジュール用のディレクトリ
node_modules/
# npm init で生成される npm パッケージの設定ファイル
package.json
# webpack 設定ファイル
webpack.config.js
# tsc --init で生成される typescript 設定ファイル
tsconfig.js
emcc-loader の設定
C/C++ のコードを wasm + js として webpack が読み込める様に、emcc-loader
をインストールします。
npm install --save emcc-loader
webpack の .clist のパス解決を Typescript の .d.ts ファイルのパス解決と上手くすり合わせるために、webpack.config.js の resolve.extensions
を追記して、webpack の拡張子の解決方法を変更します。
// ...省略
module.exports = {
// ...省略
resolve: {
extensions: [ '.ts', '.js', '.clist' ]
}
};
引き続き webpack.config.js に emcc-loader を使うための設定を module.rules
に追記します。
module.exports = {
// ...省略
module: {
rules: [
// ...省略
{
test: /\.clist$/,
use: [
{
loader: 'emcc-loader',
options: {
buildDir: `${__dirname}/temp`,
commonFlags: [ '-g', '-Wall', '-Wextra' ],
cFlags: [ '-std=c11' ],
cxxFlags: [ '-std=c++14' ],
ldFlags: [ '-s', 'DEMANGLE_SUPPORT=1' ]
}
}
]
}
]
}
};
これにより webpack が拡張子 .clist
のファイルを emcc-loader を用いて読み込むようになります。
これらの emcc-loader 用の変更を加えた最終的な webpack.config.js はGist に置いておきました。
emcc-loader の options
では主に emcc や em++ に渡すパラメータを設定しています。
詳しくは emcc-loader の README を参考にしてください。
emcc や em++ で設定できるオプションは以下が参考になると思います。
なお、webpack.config.js は拡張子から分かる通り、ただの JavaScript ファイルです。
変数宣言やら関数定義やら自由にできます。恐れることはありません。 :)
C/C++ のコーディング
Emscripten では大抵の場合そのまま C/C++ のコンパイルが通ります。
ですが、JavaScript 側から呼び出したい関数は strip の対象から外す必要があります。
そうしなければ、wasm にその関数がリンクされず、呼び出そうとしてもランタイムエラーになる事が多いです。
strip 対象から外すには、2 つ方法があります。
- C/C++ の関数の頭もしくは戻り値の型の直後に
EMSCRIPTEN_KEEPALIVE
を付ける。 - emcc で wasm にリンクする時に
EXPORTED_FUNCTIONS
パラメータで指定する。
emscripten 用のコードや新規コードでは EMSCRIPTEN_KEEPALIVE
を使い、
既存のライブラリなどは EXPORTED_FUNCTIONS
で指定するのが良いと思います。
また、それぞれの使い方は以下が参考になると思います。
- https://kripken.github.io/emscripten-site/docs/api_reference/emscripten.h.html#c.EMSCRIPTEN_KEEPALIVE
- https://kripken.github.io/emscripten-site/docs/getting_started/FAQ.html#faq-dead-code-elimination
今回は、sayHello.c
は Emscripten 以外の環境で動くことを想定して書き、
printUA.c
は Emscripten のマクロを活用して JavaScript の関数を呼ぶことにします。
// __EMSCRIPTEN__ マクロで分岐できる。
#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>
#else
#define EMSCRIPTEN_KEEPALIVE
#endif
#include <stdio.h>
EMSCRIPTEN_KEEPALIVE
void sayHello(int count) {
for (int i = 0; i < count; i++) {
printf("hello! (%d)\n", i + 1);
}
}
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <emscripten/emscripten.h>
EMSCRIPTEN_KEEPALIVE
void printUA() {
// C から JavaScript に値を渡すデモンストレーションのため、
// あえて C 言語側でプロパティ名を保持しています。
const char *propName = "userAgent";
// EM_ASM_, EM_ASM_INT, EM_ASM_DOUBLE を使って
// C/C++ の中で JavaScript が書けます。
char *result = (char*)EM_ASM_INT({
// この中で C 言語側の変数は操作出来ませんが、
// EM_ASM_* の第 2 引数以降に数値またはポインタであれば渡すことが出来ます。
// 渡した値は $0, $1, $2, ... でアクセス出来ます。
// 文字列へのポインタは、C にハードコードしている場合は Pointer_stringify で、
// ヒープ (や多分スタック) に置かれている文字列は UTF8ToString で
// JavaScript の文字列に変換できます。
const name = Pointer_stringify($0);
const str = window.navigator[name];
// JavaScript から C に文字列を返す場合は、
// lengthBytesUTF8 で '\0' を含めた文字列のバイト数を算出し、
// _malloc でヒープ領域を確保した上で、
// その領域に stringToUTF8 で書き込み、
// ポインタを C 言語側に返します。
const strSize = lengthBytesUTF8(str) + 1;
const strMemory = _malloc(strSize);
stringToUTF8(str, strMemory, strSize);
return strMemory;
}, propName);
printf("UA: %s\n", result);
// JavaScript 側で malloc しているので、ちゃんと解放しましょう。
free(result);
}
なお、1 ファイルに 1 関数となっていますが、これは emcc-loader で複数のファイルが読める事を表す以外の意図はありません。
実際のプロダクトでは通常通り 1 つのファイルの中に複数の定義をして構いませんし、ヘッダファイルも自由に使えます。
clist ファイルの記述
emcc-loader ではコンパイル/リンク対象の c/c++ (.c .cc .cpp .cxx) ファイルや LLVM bitcode ファイル (基本は .bc だが .o や .a の場合もある) をテキストで列挙する必要があります。
clist ファイルは、clist ファイルを基準とした相対パス、もしくは絶対パスで記述します。
# コメントや空行、インデントは可能ですが、
# ディレクトリやファイル名に空白は入れないでください。
./sayHello.c
./printUA.c
Typescript で wasm を読み込むための d.ts を記述
emcc-loader では Typescript 用の型情報を出力してくれないので、
自分で型定義ファイル .d.ts
を書く必要があります。
この時、clist とファイル名を一致させてください。
export interface Assembly
{
// extern "C" した関数名の先頭に _ を付ける。
_sayHello(count : number) : void;
_printUA() : void;
}
// 以下テンプレ
export interface Module
{
asm : Assembly;
}
export interface ModuleLoader
{
initialize(userModule? : any) : Promise<Module>;
}
declare const loader : ModuleLoader;
export default loader;
Typescript で wasm を読み込む
先程の定義ファイルによって、wasm の読み込みが Typescript でできるようになりました。
import mywasm from './mywasm';
async function main() {
const mod = await mywasm.initialize();
const asm = mod.asm;
asm._sayHello(5);
asm._printUA();
}
main();
ModuleLoader.initialize
の第 1 引数には、Emscripten 本来の Module オブジェクトに追加でぶら下げたいパラメータを指定することもできます。
この機能は C 側にパラメータを渡したり emcc の --pre-js
などで活用することになると思います。
emcc-loader では clist に ^./path/to/pre.js
と書くことで pre-js が指定できます。
$./path/to/post.js
で --post-js
も設定できます。
ここでは pre-js, post-js の詳細は割愛します。
ビルド・実行する
簡単にビルドや実行をするために次の npm スクリプトを package.json に書き込みます。
{
// ...省略
"scripts": {
// ...省略
"start": "webpack-dev-server",
"build": "webpack --progress --colors"
}
}
そして、以下のコマンドを叩くとローカルサーバーが立ち上がります。
npm run start
http://localhost:8080/ でアクセスできるようになります。
ブラウザの console から hello! およびブラウザの UserAgent が出力されていれば成功です。
なお、ポート番号を変えたい場合や外部からもアクセスできるようにするには webpack.config.js に以下の設定をします。
module.exports = {
// ...省略
devServer: {
host: '0.0.0.0', // これで外部からのアクセスができるようになる
port: 9999 // これでポート番号の変更ができる
}
};
webpack-dev-server では最終的なファイルはメモリに展開され、ファイルには保存されません。
次のコマンドでファイルを出力することができます。
npm run build
このコマンドにより dist ディレクトリに最終的なファイルが出力されました。
minify していないので、js ファイルのサイズが大きいかもしれませんが、
それは webpack の設定なので、他の記事に説明を譲ります。
おわりに
WebAssembly は最近のブラウザでは実装が出揃っている上、とうとう 2018 年 2 月に最初のワーキングドラフトが公開 されました。
今後、速度が求められる部分やゲームなどの分野で使われる機会が増えていくのではないでしょうか。
それでは良い Emscripten ライフを。