約2年ぶりにEmscriptenを使ってハマった点など


はじめに

この記事は、以前作ったz-music.jsを最新のEmscriptenを使ってコンパイルし直した際に出くわした問題をまとめています。「以前」というのは2017末くらいの事。WebAssembly対応が始まった頃に実験的にコンパイルしたのが最後で、公式にはasm.js版がリリース版といった感じでした。「最新」としているのは、2020年7月時点での最新版です。

もともとの動機はwasm対応ではなく、Web Audioで自動再生ができなくなった事に対する回避策の追加で、久々に環境を整えるところからスタートしたら「おや、動かんぞ」という事で四苦八苦するところから始まります。

ハマった点

Pointer_stringifyの廃止

C/C++側からJavaScriptの関数を呼び出す際、JavaScript側で受け取ったポインタからC文字列を復元するための便利関数としてModule.Pointer_stringifyという関数が存在していたのですが、これが廃止になっていました。もともと文字列を雑に扱っていた関数だったので、これが文字コードを考慮した関数に置き換わったのは歓迎すべき点かと。

新しい関数はpreamble.jsにより提供される、AsciiToStringUTF8ToStringあたり。C/C++側でUTF8を使ってるなら良いのですが、今回のケースのようにレガシーなSJIS環境から持ってくる場合には、真面目にSJISからUnicodeに変換してあげるか、強引にAsciiToStringに渡して日本語は雑に処理するか、ですね。Unicode変換自体、地雷が多いので、今回は雑に処理しました。日本語ファイル名の扱いでバグるのは諦めるとして、メッセージ表示に関しては機種依存文字、エスケープシーケンス、ユーザー定義文字などが使われていたので、適当に正規表現で置換して対処してます。

-s WASM=1がデフォルト

ハマってはいないですが、気になった変更点として、デフォルトのリンクモードがasm.jsではなくwasmになってました。今まではwasm build時に-s WASM=1を指定していたのですが、今回はasm.js build時に-s WASM=0の指定が必要でした。

Module.argumentsが変更できない

以前は、Module.preInitaddRunDependencyを呼んでEmscripten側のコードを待機させ、その間にModule.argumentsを設定・変更。removeRunDependencyで待機解除する事で、指定した引数でEmscripten側のコードを実行する事ができました。

今回のバージョンではこのタイミングで更新しても間に合わない!というか、なぜかpreInitよりも前のタイミングでModule.argumentsのコピーを作り、removeRunDependency後の実行開始時にはModule.argumentsを確認せず、事前コピーした内容を参照する、といった動作に変更になっていました。他の実行環境(nodeとか)との兼ね合いで修正されたコードがたまたまそういう挙動になっているのかも。Emscriptenで作るバイナリは大きい事も多く、実行パラメータが決まるまで読み込みを遅延させるのは得策ではないため、読み込みつつModule.arguments相当の事ができないか試してみました。

var Module = {
  // Default arguments.
  arguments: [],
  preInit: function () {
    Module.addRunDependency("initialize");
  },
  // Dirty hacks to replace previously evaluated Module.arguments during
  // Module.run().
  onRuntimeInitialized: function () {
    // Resets |calledRun| as this was already set in doRun().
    calledRun = false;
    // Resets this function so that this should not be called recursively during
    // the following run().
    Module.onRuntimeInitialized = null;
    // Calls run() again, but with the revised arguments.
    run(Module.arguments);
    // Restores the original |shouldRunNow| so that doRun() does not call main()
    // again with the original arguments after this function finishes.
    // |calledRun| should be already restored during the run() call above.
    shouldRunNow = false;
  }
};

Module.arguments = [...];
Module.removeRunDependency("initialize");

あまり行儀のよう方法ではなく、内部のコードを触ってるので、今後のバージョンでまた動かなくなる可能性は高いですが。

addRunDependencyするまでは同じで、その他にonRuntimeInitializedにもフックをかけます。これはremoveRunDependency後にC側のmainを呼ぶ直前に呼ばれます。

ここでまずcalledRunを解除。このフック自体がrunから呼ばれるのですが、そのrunを内部からもう一度呼ぶためのトリックです。この解除をしないとrunが何もせずに終了します。

続けてフック自体を削除。これは自身の呼び出すrunが再帰的にフックを呼び出すのを避けるためです。

続けてrunを新たな引数で呼び出し。これにより少し前に呼ばれていたはずのrunから新しい引数を用いて安全にやり直しができます。

最後にshouldRunNowを解除して復帰。これにより、もともとこのフックを呼んでいた最初のrunが、このフックから戻るとmainを呼び出さずにすぐに終了します。

Web XRとrequestAnimationFrame

もう1つ今時な修正をしようとしてハマったのがWeb XR対応。Emscriptenでメインループを回そうと思った時は、emscripten_set_main_loop()をfps=0で呼び出すことで登録した関数がwindow.requestAnimationFrameから定期的に呼び出されるようになります。

ところがWeb XRでXRSessionを開始した場合、window.requestAnimationFrameは実行を停止するため、代わりにXRSession.requestAnimationFrameを使いメインループを実行する必要があります。polyfillで実行しているぶんにはwindow側のループが停止しないため気づかないのですが、実機に持っていったら突然うずまきアイコンでハングアップ……何が起きているんだろう……と悩みました。

実機だけを考えた場合の対処はやるべき事をやるだけ。
XRSession.requestsAnimationFrameを使ってXR用のメインループを書き、そこからBrowser.mainLoop.runnerを呼び出せば登録されたメインループ関数が動きます。ただ、デスクトップでWeb XRのpolyfillを経由して実行している際などは、window側のループも停止しないため、毎フレーム2回ずつメインループが呼び出されるようになってしまいます。また、XRSession.requestAnimationFrame自体がwindow.requestAnimationFrameを使ってpolyfillされているため、

window.requestAnimationFrame = session.requestAnimationFrame.bind(session);

みたいな事をやると再帰呼び出しで無限ループ。このあたりは状況に合わせてうまく対処する必要がありそうです。自分の場合はwindow.requestAnimationFrameを予め乗っ取って最後のRequest IDを保存するようにし、XR開始時にwindow.cancelAnimationFrameで明示的にwindow側のループを止めることにしました。

まとめ

以上、ざっくりと。Module.argumentsなんかは小細工してないで公式に問い合わせて安定した方法を提供してもらえよって感じですけど、ひとまずはメモ書きで。落ち着いたらまた考えます。