Help us understand the problem. What is going on with this article?

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

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

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

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

結果と手法

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

結果

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

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

手法

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

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

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

SnapCrab_NoName_2020-1-19_18-50-3_No-00.png

出画は真っ暗だった。

out22.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

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ファイルシステム側への投入
  7. 実際のゲーム本体のロード

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

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

"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で要素のwidthheightを変更する方法がわからなかったので、こちらに手を入れて無理矢理表示状態を作りだしている。

NAME.wasm.code.unityweb

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

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

        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して置き換える方向とした。

    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の形式は決まっているが今回は適当に付けている。

    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);
    }
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 手続きでデータを返す簡単なものなので、

            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的な thisonmessage手続きを新設するため、wrapしたコードを呼び出す前に適当なオブジェクトに付け、後から

                this.workerobj.onmessage(ev);

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

requestAnimationFrame

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

    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実装に置き換えてみる。

okuoku
ゲーム会社だけどゲームは作ってない。
https://mjt.hatenadiary.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away