Unity WebGL なゲームをNode.JS + jsdom + headless-glで動かしたかった

えっ このネタ続くの。。？

前回( [Unity WebGLで使われているシェーダを抜き出してARBアセンブリを眺める](https://qiita.com/okuoku/items/fe1840e88c93ec415e8b) )はWebGLビルドのUnityゲームをトレースして、使われているシェーダ命令があんまり多くないことを確認した。

ブラウザ上の動作では同期APIの実装に制約がありちょっと手を入れづらいため、Node.jsで動かしたかった。別案としてNW.jsを使うというのもあったが、今回の手法でもWebブラウザ側のDOMを使わないといけないところは一応クリアしている。

# 結果と手法

結局成功したんだか失敗したんだかよくわからないところまでは来たと思う。

## 結果

- Node.jsでもUnity WebGLは起動して描画コマンドも発行する
- でも描画がまっくらなので、まだ描画が正常かどうかは確認できていない

JSdomでWebAssemblyを使ったサイトがそのまま動くのは地味にすごい気はする。

## 手法

Node.jsにはWebAssemblyがあり、Webプラットフォームの実装としては:

- **JSdom** : https://github.com/jsdom/jsdom - `createElement` とか `XMLHttpRequest` のようなDOM APIのNode.js上の実装。
- **headless-gl** : https://github.com/stackgl/headless-gl - Node.js上のWebGL実装
- **fake-indexeddb** : https://github.com/dumbmatter/fakeIndexedDB - Indexeddb APIのfake実装。Unity WebGLはEmscriptenのIDBFSをセーブデータの保存とかキャッシュに使っているっぽいのでそれ用。

のようなものが既にある。これらはUnity WebGLビルドを動かすには十分に見える。なので、 **JSdomで作成したNode.js上の仮想ブラウザ環境の `window` 、 `document` 各オブジェクトを都合よくpolyfillし、Unity WebGLが生成したWebページをそこにロードする** が基本的な方針となる。

- https://gist.github.com/okuoku/e913cf6f132602f46a6178264b289ed9
  - 適当に作ったローダー

結果、実際に描画コマンドの発行は確認でき内容は正しそうだが(`Error` と出ているのは単に`Error.captureStackTrace` でスタックトレースを拾っているからで、`glError`になるようなエラーが無いことは確認している)、

![SnapCrab_NoName_2020-1-19_18-50-3_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67446/c1d27d09-4270-a90a-bc50-e783128df72a.png)

出画は真っ暗だった。

![out22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67446/0c695ea1-c350-7512-f50a-b8762c83dcaa.png)

描画内容自体はゼロ埋めではない(濃いグレーになっている)し、描画コマンドが出ているのは確認できているので、headless-gl側の問題だと考えている。

# Unity WebGL ビルドの構造

(今回は Unity `2019.2.17f1` のWebGL 1.0ビルドを元に書いている。)

Unity WebGLビルドは、要するに Emscripten でビルドしたUnityエンジンを単に動作させているだけで、Webプラットフォームに移植されている部分は殆んどない。例外は通常のビルドではFMODを使用しているオーディオエンジンで、WebGLビルドでは自前のオーディオミキシングを.wasm側に持っているようだ。

重要な構成ファイルは4つある。これらのファイルを直接見るには、Unityのビルド設定で圧縮を事前に無効化しておく必要がある。

![SnapCrab_NoName_2020-1-19_18-18-29_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/67446/25d1aec5-cb47-1ce3-064f-7332cd660a8c.png)

## UnityLoader.js

`Build/UnityLoader.js` はJavaScript製の起動ルーチンで、こちらはUnity手製とみられる。通常のビルドではMinifyされるがDevelopment buildを選択することでMinify前のソースを見ることができる。

UnityLoaderでは:

1. ゲーム本体のIndexeddbへのキャッシュ (HTTPキャッシュを使わないのは安全と圧縮のため？)
2. ブラウザ検出とUser Agentベースでの非対応ブラウザの起動抑止
3. Emscriptenの出力に `Math.fround` の省略パッチを当てる
4. プログレスバーの出力
5. Web Workerを使用した gzip、brotli のデコード
6. アセットのEmscriptenファイルシステム側への投入
6. 実際のゲーム本体のロード

といった作業をしている。

対応ブラウザチェックは、

> "Please note that your browser is not currently supported for this Unity WebGL content. Press OK if you wish to continue anyway."

のようなメッセージを表示して起動を止めてしまう。ここは回避できなかったので今回の実験ではパッチしてしまった。

## NAME.wasm.framework.unityweb

`Build/<NAME>.wasm.framework.unityweb` はEmscriptenが出力したJavaScript側のコードで、C++(IL2CPP)部分とDOMのインターフェースは基本的にここに集約されている。

... これはEmscripteのランタイムほぼそのままなので特にコメントはなし。ただ、JSdomで要素の`width`、`height`を変更する方法がわからなかったので、こちらに手を入れて無理矢理表示状態を作りだしている。

## NAME.wasm.code.unityweb

`Build/<NAME>.wasm.code.unityweb` は、IL2CPPとEmscriptenによって生成されたエンジン本体およびゲームコードで、少くともDevelopment buildでは完全にシンボル入りになるため `wasm2wat` コマンド等で逆アセンブルできる。

例えば、jsdomで作ったElementは自動的に `width = height = 0` となるが、この状況だと:

```scheme
        call $__Z12InputProcessv
        block  ;; label = @3
          block  ;; label = @4
            call $__Z14GetPlayerPausev
            i32.const 2
            i32.eq
            br_if 0 (;@4;)
            i32.const 0
            call $_emscripten_is_webgl_context_lost
            br_if 0 (;@4;)
            call $_JS_SystemInfo_GetCurrentCanvasWidth
            i32.eqz
            br_if 0 (;@4;) ;; ★ ゼロだったらレンダリングループを中断
            call $_JS_SystemInfo_GetCurrentCanvasHeight
            i32.eqz
            br_if 0 (;@4;) ;; ★ ゼロだったらレンダリングループを中断
```

となっていて、何らかの方法でfakeしないといけないことが判る。今回は `NAME.wasm.framework.unityweb` の方をパッチした。

## NAME.data.unityweb

`Build/<NAME>.data.unityweb` はゲームアセットを格納したアーカイブで、 `UnityLoader.js` でEmscriptenのファイルシステム側に投入される。

現時点ではメモリ上に完全なファイルシステムを構築している。つまり、WebGLビルドではアセットのサイズがそのままメモリ消費量になる。

# DOM API のfake

JSdomやheadless-glでそれなりの量のAPIを実装できているが、いくつかは手で実装する必要があった。

## createElement

JSdomは実は `canvas` 要素を実装しており、 `2d` コンテキストは使うことができる。 ...が、残念ながらWebGLコンテキストはサポートしていないため自前の実装で置き換える必要がある。

今回はロードしたスクリプトから呼ばれる `createElement` のみをhookして置き換える方向とした。

- https://gist.github.com/okuoku/e913cf6f132602f46a6178264b289ed9#file-run-js-L158

```javascript
    function proxyCEl(nam){
        console.log("PROXY CEL", nam);
        if(nam == "canvas"){
            let cv = d.super_createElement("div"); // ★ 適当に div 要素でお茶を濁す
            cv.getContext = function(type, attr){ // ★ div要素に getContext メソッドを追加する
...
```

Emscripten側は実際に生成された要素が `div` であっても、 `getContext` メソッドさえ有れば正常に動作する。Duck typing。

`getContext` で得たコンテキストには WebGLDebugTools( https://github.com/KhronosGroup/WebGLDeveloperTools )を入れて呼び出しのトレースを実施している。ただ WebGLDebugTools はextensionを正常に処理できなかったため、extensionについてはhookしないように適当にパッチした。

## createObjectURL 、 revokeObjectURL

`createObjectURL` は、ブラウザ内部のポインタを指すURL(Blob URL)を生成するもので、HTML5 File APIの一部になっていて( https://www.w3.org/TR/2019/WD-FileAPI-20190911/#creating-revoking )、ここで生成したURLが`fetch`で使えるようになることが期待される( https://www.w3.org/TR/2019/WD-FileAPI-20190911/#blob-url )。しかし、JSdomにはこの機能が無いため、自前で実装してやる必要がある。

`revokeObjectURL` は、Development buildではデバッグの都合で使用されていない。(ブラウザ側のDeveloper toolsで見るのに不便だからと考えられる)

今回は `Promise` を生成するクロージャの形で適当なところに保存し、 `fetch` 操作ではその `Promise` そのものを返す(2回目以降は `Promise.resolve` で直接値を返す)ことにした。 `createObjectURL` で生成されるURLの形式は決まっているが今回は適当に付けている。

- (API側) https://gist.github.com/okuoku/e913cf6f132602f46a6178264b289ed9#file-run-js-L128

```javascript
    function createObjectURL(blob){
        let cache = false;
        function cb(){ // ★ このクロージャをBlob URLと関連付けて保存する
            if(cache){
                return Promise.resolve(cache); // ★ 2回目以降は cache を直接resolveする
            }else{
                return new Promise((res, rej) => {
                    const the_reader = new w.FileReader(); // ★ 初回はFile APIで読み出す
                    the_reader.onload = (e => {
                        const bv = the_reader.result;
                        cache = Buffer.from(bv);
                        res(cache);
                    });
                    // FIXME: ??? It seems JSDOM ArrayBuffer cannot move to
                    //        Buffer object. Use readAsText instead for now...
                    the_reader.readAsText(blob);
                });
            }
        }
        return blob_to_url(cb);
    }
```

- (fetch側) https://gist.github.com/okuoku/e913cf6f132602f46a6178264b289ed9#file-run-js-L41

```javascript
function fetch_blob(uri){ // => promise
    const r = blobs[uri]();
    return r;
}

class MyLoader extends ResourceLoader { // ★ ResourceLoaderはJSdom本来のローダー
    fetch(url, options){ // (JSdomのfetchを実装している 、 HTML5のfetchではないことに注意)
        console.log("LOADER",url,options);
        if(url == "xblob:1"){ // ★ 1番のblobはローカルファイルに差し替え
            console.log("PATCH!!!");
            const buf = fs.readFileSync(path.resolve(__dirname, "patch1.js"));
            return Promise.resolve(buf);
        }else if(url.indexOf("xblob:") != -1){
            return fetch_blob(url); // ★ Promiseを生成して返す
        }else{
            return super.fetch(url, options); // ★ blob以外では本来のローダーを使う
        }
    }
}
```

Unityでは、この `createObjectURL` は、JavaScriptで展開される可能性のある `.unityweb` を保持するのに使用している。このため、`MyLoader`クラスにはデバッグ用にファイルを差し替える機能も付けている。

## Web Workers

UnityではHTTP経由で取得するファイルを解凍するためにWeb workerを使用している。実際の展開処理はプレイヤー設定で "Uncompressed" を選べばskipでき、そのためのコードは非常に単純なため、今回は真面目にWeb Workerを実装するのではなく単に関数だけ実行できるようにした。

Unityが供給するWeb workerコードは、 `this.onmessage` ハンドラでコマンドを受けとり、グローバルの `postMessage` 手続きでデータを返す簡単なものなので、

- https://gist.github.com/okuoku/e913cf6f132602f46a6178264b289ed9#file-run-js-L65

```javascript
            const text = buf.toString();
            const src = "const obj = function (postMessage) {" + text + "}; obj";
            const ex = eval(src); // ★ 元のUnityのWorkerコードをwrapしたもの
            const home = this;
            const recvmsg = function(x,y){
                if(home.the_handler){
                    let ev = {data: x};
                    console.log("RECVMSG", x, y);
                    home.the_handler(ev);
                }else{
                    console.log("!! Q RECV MSG", x, y);
                    home.recvq.push([x,y]);
                }
            }
            this.workerobj = {};
            this.workerobj._init = ex; // ★ 元のコードが this にアクセスしているため、適当なオブジェクトに付ける
            this.workerobj._init(recvmsg);
            console.log("WORKER STANDUP", this.workerobj);
```

のように `function(postMessage){ /* 元のUnityのWorkerコード */}` を `eval` してWorkerコードのグローバル変数 `postMessage` をエミュレートする形にした。

元のコードはJavaScript的な `this` に`onmessage`手続きを新設するため、wrapしたコードを呼び出す前に適当なオブジェクトに付け、後から

- https://gist.github.com/okuoku/e913cf6f132602f46a6178264b289ed9#file-run-js-L87

```javascript
                this.workerobj.onmessage(ev);
```

のように使用(Workerへのメッセージングのエミュレート)している。

## requestAnimationFrame

`requestAnimationFrame` は、通常のブラウザではVSYNC(物理的な画面の更新完了)のたびに呼ばれる。ただし、今回は面倒なのでフリーラン(全力で描画する)とした。つまり、単にNode.jsの `process.nextTick` にコールバックを登録して即呼び出すだけとしている。

- https://gist.github.com/okuoku/e913cf6f132602f46a6178264b289ed9#file-run-js-L213

```javascript
    function sleep(ms){
        return new Promise((res) => setTimeout(res, ms));
    }

    function proxyRAF(cb){
        //console.log("RAF", cb);
        process.nextTick(async function(){
            //await sleep(100); // ★ 描画速度の調整用
            const now = w.performance.now(); // ★ JSdomの Window.performance を使用して現在時刻を返す
            console.log("RAF", now);
            cb(now);
            update_screenshot();
        });
        return 99.99; // ★ これは cancel 用だが使われないので適当な値を返す
    }
```

# かんそう

... いきなりUnityをやらないで、Emscriptenで書いたコードで小さく試すべきだったね。。ただ、もう必要なWebAPIは揃えてしまったし面倒なところは結局Unity固有だったので何とも。。

次はheadless-glを自前のWebGL実装に置き換えてみる。