Unity WebGLをwasm2cでC言語に変換して動かす

13 min read読了の目安(約11700字

(Unity-chan: © Unity Technologies Japan/UCL)

さすがにこの規模のアプリケーションをwasm2cして動作させている例は他に無いんじゃないかという気がする。PhysXとか入ってるし。

何ができたの

Unityはそのライセンスで派生物の作成を禁止しているため、現時点では簡単に再現できるような環境は用意していない。(原理上、ビルドしたUnityプロジェクトと実行環境が実行時に不可分になってしまうため、そこの調整が必要。)

  1. Node.jsでUnity WebGLのアプリを動くようにした (OpenGL ES2とSDLを使用)
  2. 更に、WebAssembly部分を wasm2c でネイティブコードにAOTコンパイルできるようにした

将来的にはNode.jsも動作には不要にできるはずだが、今のところそこには至っていない。

これにより、JavaScriptインタプリタさえ何とかすれば、Cで書かれたSDLプログラムと同等の移植性をUnity WebGL出力にもたせることができた。

動機

個人的に製作中のゲームの開発/オーサリング環境をWebAssemblyに集約する上で、それなりの規模のWebAssemblyアプリケーションを動作させることで必要な機能性の検討を行いたかった。

開発環境をWebAssemblyに集約した上で、各機種向けのバイナリはAOTコンパイルまたはインタプリタ内蔵で作成しようとしているので、そのための方法として、今回はwasm2cを試してみることにした。

使っているもの

  • Node.JS (v8のWebAssemblyは使ってない -- 使っても動くようにはしてあるけど)
  • wabt のwasm2c
  • VisualStudio付属のclang (wasm2cの出力はMSのコンパイラでコンパイルできないため)
  • VisualStudio付属のCMake
  • PowerVR SDKに付属のOpenGL ES2エミュレータ
  • Vulkan SDKに付属のSDL2
  • ffi-napi モジュール
  • Unity 2021.1.0b1
  • UTSv2のサンプルプロジェクト

UnityのWebGL出力自体はEmscripten 1.38.xでできている。

作ったもの

まだ他でつかえるようには整理していない。

  1. WebGLのC言語バインディング (C-WebGL)と、 それを利用したWebGL実装
  2. 超ウルトラスーパー適当な ダミーDOM実装
  3. NCCC ポータブルFFI仕様
  4. NCCC の Node.js用ブリッジ (N-APIで構築) と、それを使用したAOT向けの WebAssembly Embedding APIwasm2cブリッジ
  5. NCCC の stubジェネレータ (CMakeで実装)

制作過程はスクラップにメモっている。というかスクラップなしではここまで辿り付けなかったと思う(間違いなく途中で飽きる)。

https://zenn.dev/okuoku/scraps/a0a74834db15e6

構成

UnityのWebGLビルドは、JavaScript側とWebAssembly側に分かれる。WebAssemblyから直接WebGLを呼出すことは不可能なので、WebGLのようなAPIは基本的にJavaScript側から呼ばれることになる。

WebAssembly部分は、wasm2cで変換され、単一のDLLとしてロードされる。つまり、実行時にはWebAssemblyの処理系は必要なく、Node.jsからは単なる外部関数の呼出しに見えている。(デバッグ用に、Node.jsのWebAssembly機能を使用しても実行できるようにはしている。)

基本的には気合と根性で必要なAPIを実装している。現時点ではWebAssembly←→JavaScriptの間でだけ NCCC を使用し、C-WebGLとJavaScript側の呼出しは ffi-napi のままになっている。

元々はNCCC呼出し規約も ffi-napi で実装していたが、超遅かったので動作確認もそこそこに自前のものに置き換えてしまった。

実装

(JavaScript側は割とめちゃめちゃになっているので省略)

Unity WebGLビルドは、アセット .data 、HTML Page、ローダー .loader.js 、EmscriptenとUnityのランタイムコード .framework.js 、WebAssemblyによるゲーム本体 .wasm をそれぞれ出力する。このうち、HTML pageとそこで使われるローダーは今回は使用しない。

アセットは事前にunpackしてファイルシステムに置いておく必要がある。この処理は .loader.js 側を事前に実行することでも行えるが、そもそもpackされた形式がパスとファイルを連結しただけなので独立したコードで実施している。

.wasm はいくつかのステップを経て最終的に NCCC でインターフェースされるDLLにコンパイルされる。

★ 出力例はUnityのものではなく、Emscriptenで出力した普通のC++プログラムのものになっている

  1. wasm2c を使用して、WebAssemblyをC言語に変換する。このとき、 .c だけでなく、その内部で宣言される関数のためにヘッダファイル .h も同時に出力される。
  2. 2本のCMakeスクリプト ParseW2cSourceParseW2cHeader で、それぞれソースとヘッダを正規表現で処理してWebAssembly内で宣言されているimport/exportやそれらの型情報を抽出し、CMakeスクリプトの形でシリアライズする。出力例: ソース(Tableの型情報)ヘッダ(import/export情報)
  3. さらにHeader中のシンボル情報はデマングル等の処理を行うために W2cDataGen スクリプトにも通す。出力例: シンボル情報
  4. 以上の処理で得られたメタデータを TemplateW2c スクリプトに通し、 C言語マクロの形でシリアライズする
  5. 生成されたヘッダファイルと、そのヘッダファイルからNCCCスタブを生成する stub.cwasm2cのためのランタイムコードを含む rt.c と、wasm2cの出力をコンパイルし、最終的なアプリケーションである appdll.c を得る

Node.jsは .framework.jseval して 本体を実行する。このとき、追加のコードを同時にevalして必要な変数を取得している。

appdll.dll には、NCCCを呼出しプロトコルとする4種類のC関数が収録されている。

  • 管理用ルーチン (コアライブラリ)。これらは rt.cに直接実装されていてwasm2c で要求されているランタイムルーチンや、後述の各種stubのアドレスや型情報を引くための機能を提供する。
  • forward stubwasm2c で変換されたCソースコードは通常のC言語関数ポインタの形で .wasm 内からexportされる関数を提供するが、それらの関数をNCCCとして呼び出せるようなブリッジを内蔵している。これらは stub.c 内のマクロで生成される 。マクロには戻り値の有無 x 引数の有無 で4パタンが定義されている。
  • backward stub 。forward stubとは逆に、.wasm 内のコードからJavaScript側を呼出す際に、呼出しプロトコルをNCCCに変換するstubを内蔵している。これも、 stub.c 内のマクロ で生成される。
  • 関数ポインタ用のブリッジ 。これはforward stubのバリアントに相当し、 同様にマクロで定義される が、暗黙の追加引数として、呼出し対象の関数ポインタが先頭の引数として追加されている。

いわゆるDLLとしてエクスポートしている関数は the_module_root 1つだけであり、これがコアライブラリとしてのディスパッチを提供している。他のNCCC関数は、コアライブラリを使用して関数ポインタを取得して呼出すことになる。

NCCCの呼出しロジックは可能な限りヒープオブジェクトを作成しないように、JavaScriptのコールスタックから直接NCCC引数を構築する ( backward stubの場合はその逆 )。ただし、JavaScriptにはSchemeのような多値が提供されていないため、 NCCC関数が複数の値を返却する場合は値を配列に入れて返却する ためアロケーションが発生してしまう。C言語では多値を返却する関数は定義されない = パフォーマンスクリティカルな状況では多値は使わないと判断して妥協している。

デザインチョイス

いくつかstraightforwardでないデザインチョイスがある。

C-WebGL

WebGL1自体はOpenGL ES2を元にしているため、OpenGL ES2のFFIバインディングを用意して直接使えばいいように思える。しかし、今回はWebGLのセマンティクスに忠実なC言語バインディング(C-WebGL)を用意し、C言語側でOpenGL ES2を使用した実装と、JavaScript側のライブラリの合わせ技で実装する形をとった。理由は幾つかあるが:

  1. 将来的なVulkan/Metalへの移植 。特にiOSではOpenGL ESは非推奨となっているため、代替手段が必要となる。既にGoogleのANGLEがMetalにも対応しているが、ビルドが面倒だったりパフォーマンスの心配があるので手で実装してしまっても良いような気がしている。
  2. 将来的なマルチコンテキストへの対応 。OpenGLは単一のコンテキストを想定したAPIであるため、WebGLのように複数のコンテキストを平行して使用することができない。C-WebGLは 全ての呼出しに ctx パラメタでコンテキストの明示を求めている
  3. オブジェクト表現方式の違い 。OpenGLではint巾の整数のハンドルを使用してテクスチャ等のオブジェクトを識別するが、WebGLではJavaScript的なオブジェクトを使用し、かつ、GCにも対応する必要がある。
  4. 余計なものが少い 。WebGL1とOpenGL ES2には微妙な仕様差があるが、特にWebGL1ではclient side array等いくつかの機能をオミットしているため、OpenGL ES2に比べて少々コンパクトになっている。

また、どうせそのうちWebGPUが出てきてそちらに対応する必要があるので、最初から熱心にOpenGLのFFIバインディングを用意する気になれなかったというのもある。 WebGPUには最初からCバインディングが存在 する。

Node.jsで動作するWebGLとしては headless-gl 等があるが、OpenGL ES実装に選択の余地がなくデバッグが大変そうなので自前で用意することにした。APIトレースなしでGPUプログラミングするのはマジでつらい。

https://zenn.dev/okuoku/articles/dccb1d0587ba57

NCCC (Normalized C Calling Convention)

libffiや dyncall を使用したFFIではなく、全ての外部関数のシグネチャを

void function(const uint64_t* in, uint64_t* out);

に限定し、必要なスタブ関数をビルドシステムで生成する NCCC を考案し使用している。

実装が簡単でABI依存が無いので移植性を高められるというメリットがある。Node.js + wasm2c の組合せしか無い現状ではあまりベネフィットは無いが、例えば将来 Duktape + WASM3 みたいな任意の (JavaScript処理系) x (WASM処理系) の組み合わせを実現しようとした場合には、実装の容易性が効いてくるものと期待している。

64bit値の処理

JavaScriptでは64bitの整数をフルに表現することができないが、↑に書いたようにNCCCでは uint64_t を基本単位として使用している。今回は 64bit値ではBigIntを使わず、SAFE_INTEGERの範囲でNumberを使用する 方針にしている。

  1. BigIntはおそらくヒープオブジェクトとして実装されるためGC負荷がかかり、パフォーマンスインパクトが大きいと予想される
  2. AMD64のCanonical Addressは48bit範囲内であり、ポインタは常にSAFE_INTEGERに収まることが保証されている -- ただし、現代はARM64の増加傾向によってAMD64のシェアが低下してきており、iOSのようにtagged pointerを導入しているケースがあるためこの仮定はそろそろ危険になってきている。
  3. WebAssembly内部のポインタは32bitに収まる
  4. Duktapeのような一部のJavaScript処理系はBigInt自体に対応していない

ただ基本的に浮動小数点が無料で利用できるJavaScriptと違って、浮動小数点をヒープオブジェクトにしている処理系もあるため、もうちょっと真剣にこの問題を考えないといけないかもしれない。

リトルエンディアン専用

WebAssemblyはリトルエンディアン であるため、実装しているランタイムもリトルエンディアンであることを前提としている。

WebGL部分はEmscriptenを想定したサブセット

WebGL部分は完全な実装ではなく、EmscriptenのOpenGL ES2互換レイヤを前提に、ステートのクエリやDOMを使用した画像のデコードやテクスチャアップロード/ダウンロード時のY-Flip等をオミットしている。

Ejecta のようなランタイムでは比較的真面目にWebGLを実装することでthree.jsのような純粋なWebアプリもサポートしているが、あんまりユースケースが思いつかなかった。

JavaScript部分をそのまま使う

この手のシステムを実装する場合、Emscriptenのランタイムをどうするかが問題になる。今回は、Node.jsにEmscriptenのランタイムをロードしてそのまま使うことにした。

このため、このシステムにUnity固有の部分は殆どない(Emscriptenのidbfsをそのまま使用しているファイルシステムレイアウトのみ、idbfsを使用しないための専用の対応をしている)。

Pros:

  • リーガル要求 。Unityの出力を変更せずに使うことと、Unityの(事実上の)リバースエンジニアリングを行わないことが要求としてあり、Unity互換ランタイムの実装はEULAに抵触する。
  • Unityはオーディオ部分でEmscriptenを使用せず独自実装しており 、自前でランタイムを実装してしまうとそれらにも対応する必要がある。
  • EmscriptenはNode.jsに対応しており、Unityの出力にも対応コードがそのまま残るため対応が容易

Cons:

  • WASM処理系だけでなくJavaScript処理系も用意する必要がある
  • パフォーマンスが心配。現在は地上最強と言って良いv8で動かしているのでffi-napiを除いて特にパフォーマンス的に気になる点は無いが、duktapeのような純粋なインタプリタに移植する際には問題になるかもしれない。
  • WebAssembly (embedding API)とか DOMのサブセットを比較的真面目に実装する必要がある。

かんそう

かんたんだった(KONAMI)

最初にNode.jsのWebAssemblyを使用して 絵出しして から約1ヶ月は、Godot とか Dosbox のようなUnity以外のEmscriptenアプリの対応をして、それからwasm2cの作業を始めている。

一応 生成された.cにトラップを仕込む といった方法でデバッグが可能ということもわかったので、当初心配していた程開発が困難ということもなかった。

ただ実用性という面では。。そもそもUnity WebGLがあんまり。。

ロードマップ

直近では、 ffi-napi への依存を無くし、完全に NCCC だけでFFIを実現したいと考えている。 ffi-napi は非常に遅く、NCCCへの移行前はUnityが10分掛けても起動しなかったり、Node.jsのWebAssemblyで60fpsで動作していたサンプルが 2fpsしか出なくなったり と散々だった。

その次は Node.js なしでも動作するようにしたい。代替となるJavaScriptインタプリタとしてはduktapeかQuickJSあたりを検討している。また、イテレーション時間を改善するためには、WASMインタプリタの組込みも行う必要がある。今だと .DLLをビルドするだけで数分掛かってる ので。。

履歴

https://qiita.com/okuoku/items/f4d5b0e97429598cf33f

https://qiita.com/okuoku/items/60ea7bb9c20b38505531

https://zenn.dev/okuoku/articles/b162423e1367e8

Cygwinでwabtを動くようにしたPRが去年2月なので、ネタとしては大体一周年というところか。

https://github.com/WebAssembly/wabt/pull/1332

しばらく寝かせていたところにZennのスクラップ機能が来たので、プロジェクトの履歴を残すために使ってみようということで1ヶ月ほど集中して取り組んでみた。