えっ このネタ続くの。。?
前回( Unity WebGLで使われているシェーダを抜き出してARBアセンブリを眺める )は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ページをそこにロードする が基本的な方針となる。
結果、実際に描画コマンドの発行は確認でき内容は正しそうだが(Error
と出ているのは単にError.captureStackTrace
でスタックトレースを拾っているからで、glError
になるようなエラーが無いことは確認している)、
出画は真っ暗だった。
描画内容自体はゼロ埋めではない(濃いグレーになっている)し、描画コマンドが出ているのは確認できているので、headless-gl側の問題だと考えている。
Unity WebGL ビルドの構造
(今回は Unity 2019.2.17f1
のWebGL 1.0ビルドを元に書いている。)
Unity WebGLビルドは、要するに Emscripten でビルドしたUnityエンジンを単に動作させているだけで、Webプラットフォームに移植されている部分は殆んどない。例外は通常のビルドではFMODを使用しているオーディオエンジンで、WebGLビルドでは自前のオーディオミキシングを.wasm側に持っているようだ。
重要な構成ファイルは4つある。これらのファイルを直接見るには、Unityのビルド設定で圧縮を事前に無効化しておく必要がある。
UnityLoader.js
Build/UnityLoader.js
はJavaScript製の起動ルーチンで、こちらはUnity手製とみられる。通常のビルドではMinifyされるがDevelopment buildを選択することでMinify前のソースを見ることができる。
UnityLoaderでは:
- ゲーム本体のIndexeddbへのキャッシュ (HTTPキャッシュを使わないのは安全と圧縮のため?)
- ブラウザ検出とUser Agentベースでの非対応ブラウザの起動抑止
- Emscriptenの出力に
Math.fround
の省略パッチを当てる - プログレスバーの出力
- Web Workerを使用した gzip、brotli のデコード
- アセットのEmscriptenファイルシステム側への投入
- 実際のゲーム本体のロード
といった作業をしている。
対応ブラウザチェックは、
"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
となるが、この状況だと:
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的な this
にonmessage
手続きを新設するため、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実装に置き換えてみる。