🍓

Unityを勝手にRaspberry Pi3に移植する実験

8 min read

ネタバレ: 遅い

https://twitter.com/okuoku/status/1365575725722439686

前回はwasm2cで変換したネイティブ部分と Node.js の組合せで動いていたが、JavaScript依存部分をDuktapeに移植したのでCコンパイラさえあれば何とかなるようになった。

というわけで、Unity WebGLで出力したゲームを wasm2c 経由でネイティブコードに変換し再ビルドしてみた。専用のゲームを用意すればあるいは。。という感じか。音声出力は未実装。

Unityはそのライセンスで派生物の作成を禁止しているため、簡単に再現できるような環境は用意していない。

やったこと

UnityにはWeb版がある → Web版はEmscriptenでコンパイルされたWebAssemblyになっている → wasm2c でC言語に変換したらソースコードに戻るじゃん! → 再コンパイルすればどこでも移植できるじゃん!

... というのが前回

https://zenn.dev/okuoku/articles/5a7a04e75234b3

今回はこれをRaspberry Piで動作させてみた。

動作原理

Unityには出力オプションの1つに WebGL ビルドがあり、これはLLVM-IRの形で配布されているUnityのバイナリと、IL2CPPで変換したC#スクリプトをEmscriptenで処理する形で実装している。

https://qiita.com/okuoku/items/2e85d4e22a0a5acb3ae1

このため、WebAssemblyを実行する環境とEmscriptenの出力するJavaScriptを実行する環境さえ揃えてしまえば、Webブラウザに頼らずに出力を実行できる。

WebAssemblyについては、 wasm2c でC言語に変換した上でCコンパイラでネイティブコードに変換している。これは 前回書いた

JavaScriptに関しては、前回はNode.jsを使ったがNode.jsのメモリ消費が心配だったので今回はDuktape( https://duktape.org/ )に手で移植している。Duktapeはソースコードが.cと.hひとつづつというSQLiteの如くシンプルなインテグレーションが出来、互換性や速度も申し分ない。 (速度が足りなかったらQuickJSを使おうと思っていたが十分だった。)

つらい

PCでは特に何の工夫もなく動いていたものでも、イザRaspberry Piにもっていくとなると大仕事に。。

メモリが全然足りない

最初はRaspberry Pi 1で動かすつもりで諸々準備していたものの、gdbと一緒に起動することすら厳しい状況で諦めてRaspberry Pi 3に移行してしまった。

移行後も、命令セットとしてはARMv6を使いつづけているので、専用のゲームを用意すれば 1 でも動作はするのではないだろうか。。

Broadcom謹製OpenGL実装がバグっている

どうも巨大なMIPMAPテクスチャをアップロードするとカーネルのメモリを破壊するらしく、カーネルパニックを起こしてしまう。

(↑ こんな感じに急に画面が壊れるからビックリする)

結局Broadcomのユーザモードドライバを手でビルドしてステップ実行することで クラッシュさせたコマンドを突き止めた が、どうしようもないのでMesaに乗り換えた。

Raspberry Pi OSでは raspi-config コマンドでOpenGL実装を3種類から選ぶことができる。

  1. Legacy 。Broadcom謹製の実装。内蔵DSP上で動作するOSがOpenGLコマンドを処理してくれるためちょっと軽い(特にRPi 1で)。
  2. Fake KMS 。ビデオ出力の設定にBroadcomの実装(dispmanx)を使いつつ、OpenGLのコマンド生成処理はCPU側のオープンソース実装(Mesa)を使う。
  3. Full KMS 。ビデオ出力の設定にはLinuxカーネルのKMSを使い、OpenGLにはMesaを使う。

今回はFull KMSを選んだ。というか今となっては Full KMSとLegacyの2択で良くない。。?

Depth+Stencil attachmentが使えない

... 何故。。? Mesaを使ってもllvmpipeではDepth+Stencil attachmentが使えるのに対し、Raspberry PiのVideoCore IVでは使えないという絶妙な差があった。

もっとも、これをエミュレートするのは簡単で、DepthとStencilの両方に同じバッファをアタッチしてしまえば良い。

https://github.com/okuoku/cwgl-proto/blob/30c22be48b060ed5928c6aabb9cc88f3f9026893/jstestapp/webgl-cwgl.js#L499-L500
framebufferRenderbuffer: function(target, attachment, renderbuffertarget, renderbuffer){
    if(attachment == E.DEPTH_STENCIL_ATTACHMENT){
        CWGL.cwgl_framebufferRenderbuffer(ctx, target, E.DEPTH_ATTACHMENT, renderbuffertarget, objptr(renderbuffer));
        CWGL.cwgl_framebufferRenderbuffer(ctx, target, E.STENCIL_ATTACHMENT, renderbuffertarget, objptr(renderbuffer));
    }else{
        CWGL.cwgl_framebufferRenderbuffer(ctx, target, attachment, renderbuffertarget, objptr(renderbuffer));
    }
}

(Broadcom謹製のOpenGLでは、そもそも OES_packed_depth_stencil 拡張 に対応していないため、この点でもBroadcom謹製のOpenGLは候補から落ちてしまうことになる。)

C言語の未定義挙動で容赦なく死ぬ

https://github.com/okuoku/cwgl-proto/commit/756cbdc5f9e12ffdf1ffa477bb819c30dc54dfb2
diff --git a/duk-nccc/duk-nccc.c b/duk-nccc/duk-nccc.c
index 0021715..9fe2f6f 100644
--- a/duk-nccc/duk-nccc.c
+++ b/duk-nccc/duk-nccc.c
@@ -82,7 +82,7 @@ value_out(duk_context* ctx, uint64_t* out, char type, duk_idx_t vin){
             }else if(get_pointer(ctx, vin, &v)){
                 *out = v;
             }else{
-                *out = duk_require_number(ctx, vin);
+                *out = (int64_t)duk_require_number(ctx, vin);
             }
             break;
         case 'f':

この変更をしないと、ARMでは *out に負値が代入できず 代わりにゼロが代入された 。AMD64やi386では doubleuint64_t で受けても問題なく負値が得られるんだけど。。

事前に値がわかる場合はちゃんと警告される。例えば:

#include <stdio.h>
#include <stdint.h>

void
n(uint64_t x){
    printf("%lld\n",x);
}

int
main(int ac, char** av){
    n(-89.0f);
}

のようなコードをコンパイルすると:

xcheck.c:11:7: warning: overflow in conversion from ‘float’ to ‘uint64_t’ {aka 
‘long unsigned int’} changes value from ‘-8.9e+1f’ to ‘0’ [-Woverflow]
   11 |     n(-89.0f);
      |       ^~~~~~

のように警告される。この場合はamd64でもちゃんとゼロになるが、値が事前に予測できないケースではamd64とARMで挙動が違うようだ。

ARMの命令セットではアドレス空間が足りない

UnityのWebAssembly出力は30MiB程度あり、これを wasm2c でC言語に変換すると200MiBを越える巨大なソースコードになる。これをコンパイルしてできる実行ファイルも当然巨大になるが、

/tmp/cceyILfN.s:40419112: Error: Thumb2 branch out of range

のようなメッセージが出てコンパイルに失敗してしまう。

ARMにはRISCによくある制約としてジャンプ命令で飛べる範囲に制約があり、かつ、GCCはその制約にあわせて自動的に生成する命令列を変えたりしてくれない。

今回は、LTO(リンク時最適化)で最適化することで実行ファイルのサイズを節約する作戦とした。

-flto -g -fvisibility=hidden -Os

代償として、 ビルドには1時間くらい掛かるwasm2c で.cを出すだけでも10分くらい掛かるがこれは単に wasm2c の実装が悪い(バッファリングしていない)ためで高速化の余地はあるものの、ただビルドするだけでLTOが必須というのは中々厳しいものがある。

-mlong_calls のような回避用のオプションも有るが、今回のケースでは使えなかった。そもそも関数間が距離制約に収まらないため現状の呼出し規約で呼べなくなる。

DuktapeでEmscripten出力を動かす

DuktapeはECMAScript 5 の実装であるため、ES Modulesやアロー構文のような便利機能はない。まぁその辺はRollupなりBabelなりでどうにでもなるので良いとして、いくつかPolyfillしてやる必要がある。

特にUnity WebGLでは、 copyWithin とか clz32 のようなIE11では実装されていないJavaScript仕様に依存しているため、これらを補わないと動作させることができない。

今回はこれらはネイティブ側で実装することにした。例えば clz32 であれば、

Math.clz32 = BOOTSTRAP.clz32;
/* clz32 */
static duk_ret_t
clz32(duk_context* ctx){
    const double d = duk_require_number(ctx, 0);
    const uint32_t u = d;

    uint32_t ret;
    if(u == 0){
        ret = 32;
    }else{
        ret = __builtin_clz(u);
    }
    duk_push_number(ctx, ret);
    return 1;
}

他に Promise のpolyfillは https://www.npmjs.com/package/promise-polyfill から拾ってきたものをそのまま使っていたりする。

WebAssemblyのembedding APIは前回Node.js用に実装したものを完全に流用している。

かんそう

予想以上にたいへんだった。

WebGL + Emscripten経由の移植は実用的か?

Unity自体は大きなプロジェクトのためにソースコードライセンスを出しているが、個人には出していない。

https://twitter.com/kyubuns/status/1365112676595101698

このため、現状のUnityがサポートしていない環境でUnityをネイティブ動作させようとするとWebGL実装からの移植をするしか無いのではないかと思う。

... まぁRaspberry Piではご覧の通りの残念な結果ではあるけど、NVIDIA Jetsonのような強力なボードではワンチャン有るんではないかという気はする。

wasm2c ワークフローを高速化できないか?

wasm2c 自体は出力段が非効率なので遅いという面もあるが、 1つのWebAssemblyを大量のCソース群に変換する 機能が必要なのではないかと考えている。

というのは1つの巨大なCソースを出力してしまうと、それをコンパイルするだけで大量のメモリが必要になってしまうという問題がある。出力するCソースを自動的に適当な単位に分割してくれれば、もうちょっとイテレーションも楽になるはず。。

wasm2c を "実用的" に使用した例としてはWasmBoxCがある。

https://kripken.github.io/blog/wasm/2020/07/27/wasmboxc.html

C変換の高速化はこの手のソリューションの価値を高めることにもなる。

Webブラウザでよくない?

別にWebブラウザでも良いと思うけど、今回の移植でRaspberry PiのGPUパフォーマンスを最大に引き出してコレなので、間に抽象化やコンポジションの入るWebブラウザではより厳しい戦いになるのではないだろうか。

Raspberry PiでWebGLのハードウェアアクセラレーションに対応したWebブラウザとしてはWPE WebKitがある。

https://wpewebkit.org/

これもEmscriptenで動かしてみたいが、スレッドが必須だったり posix_spawn どうすんのかといった問題もあるのでかなり難易度は高そう。

Future works

ちょっとRaspberry Piは卒業かな。。専用のゲームエンジンを組んだ方が良さそう。

他のWASM処理系にも対応したい 。今回CPU面では割と余裕がありそうということが判ってきたので、他のWebAssembly処理系にも対応できるとテストベッドとして面白いんではないかと思う。

完全なWebAssembly化 。今のところDuktapeはCコンパイラで直接コンパイルしているが、この部分も原理的にはWebAssemblyにすることが可能と言える。完全にWebAssembly化しておけば、ステートのセーブ & ロードや TAS対応 といった応用も考えられる。

スレッド対応 。実はUnity WebGLにもスレッド対応オプション自体は存在するが、C#的なスレッドが使えるようになるものではないので実用性には欠ける。でも近代的なゲームの動作には必須なので何か考えたい所ではある。

自前のOpenGL ES実装 。今はWebGL実装は単にコマンドをOpenGL ESにリレーしているだけであんまり実用性が無いが、Vulkanか何かの上にWebGL実装を用意することでマルチコンテキストを容易に実現できる気がする。今のところWebGLコンテキストはプロセスで1つであることを前提にしているが、それだと追加のUIとかOn Screen Keyboardを描いたりしづらい。

今のところターゲットはEmscriptenの出力に絞っている。PlayCanvasとかBabylonのようなHTML5ネイティブエンジンは必要なDOM要素が多すぎてちょっと実装し切る自信が無い。。