0
Help us understand the problem. What are the problem?

posted at

updated at

初心者がWebXR+emscriptenでドはまりした話

はじめに

この記事はWeb系技術の初心者がいきなりWebXRを使おうとしてドはまりした話ですので「知見」という意味ではあまり期待なさらぬようお願いします。
もしこの分野に初めて取り掛かる人がおられましたら、多少の参考になれば幸いです。
なお、WebXRについての記事ですが所謂immersive-vrのみの話になります。ARの方の話は出てきません。
注意点:わたくしこの分野に関してはホンマもんの初心者なので「マサカリ」は勘弁してください。そんなもん投げられたら初心者は何も発信できなくなります。間違いがあればくれぐれも「やさしく」ご指導ください

なにも分からない…なにもかも

ひょんなことからWebXRをやることになったのですが、VRもほとんどやったことはないし、web系の技術初心者のためWebXRどころかWebGLやそもそもJavaScriptに対しての理解が浅すぎて様々なことでドはまりしました(ハマりポイントは後述します)

JavaScriptくんの気持ちがわからない

直前までのお仕事がC++&DirectXメインだったのですが、今回WebXRを触るにあたってJavaScriptのプログラムを書くことになりました。大昔20年以上前・・・本当にプログラミングやり始めの頃、Netscape Navigatorの時代にちょこっと触ったくらいで、その頃のイメージのまま今回WebXR Samplesのソースコードを読んでみました。https://immersive-web.github.io/webxr-samples/

var , let , const

constはC/C++でも存在するキーワードのため再代入不可というのは分かるのですが、varとletってどう使い分けるの…?変数というのは分かるんだけど…。
で、調べた結果varは関数スコープ、letは{}ブロックスコープとのこと。C/C++を長くやってきた僕としてはletの方を使ったほうがよさそうです。

Promise then async await

JavaScriptはブラウザ上で動作することを想定しているためか非同期処理が充実していてとても助かるのですが、知らずにコード読むと「なにこれ?」となります(なりました)。
WebXR apiの提供する関数のいくつかはPromiseオブジェクトを返します。

通常の関数であればその関数の処理が終わった後に関数の次の行を実行する(完了復帰)のですが、Promiseオブジェクトを返す関数の場合別スレッドで関数の中の処理が実行され、呼び出し側から見ると「即時復帰」します。

このため、前の関数の処理の後に「前の関数の処理が終わった前提のコード」を書くと、実は終わっておらず不具合に悩まされるという事になります。
いつ頃処理が終わるのか分からないため、終わった後の処理をthenキーワードを用いて定義します。

非同期関数().then(終了後に実行したい処理の関数);

こんな形になります。たいていはこのthenの後はラムダ式的な感じで

非同期関数().then((引数)=>{終了後に実行したい処理});

といった風に記述されます。

また、とにかく関数が終わるまで待ちたい時はawaitキーワードを使用します。上の例ならthenを書く代わりに

await 非同期関数();

とします。こっちのほうがシンプルなのですが、これだと即時復帰関数の意味がなくなってしまうので、良し悪しな感じしますね。

メインループはどこ?

C++&DirectXとかでゲーム作ってると最初に探すのが「メインループ」なのですが、どこにもそれっぽい箇所が見当たらない。
もしくはUnityとかにあるようなUpdate関数にあたるものも見当たらない。いったいどうやってゲームループ的なものを構成しているのか?

ブラウザの描画時に呼ばれる関数はコールバック的な形で登録して上げないといけないようです。それが
Window.requestAnimationFrame関数です
https://developer.mozilla.org/ja/docs/Web/API/Window/requestAnimationFrame
で、例えばUpdate関数を作ったとして、それを

Window.requestAnimationFrame(Update);

とすればそれで終わるのかというとそうではないようで、このコールバックは1回呼ばれたら解除されるようです。
つまり、毎フレームUpdate関数を呼びたければ

function Update(){
    //描画時にやりたい処理
    Window.requestAnimationFrame(Update);
}
Window.requestAnimationFrame(Update);

といった風に書きます。
このへん、慣れてる人にとっては当たり前なのかもしれませんが、初見ではかなりハマりました。

そしてWebXRの場合はこれがWindowではなく、XRSession.requestAnimationFrame(Update);という風になります。本当に初見だと何やってるかわからりませんでした。

WebXRがよくわからん

JavaScriptの理解が浅いからなのかVRの理解が浅いからなのかWebXRの仕組みが分からずかなり苦労しました。
そもそも資料がまだまだ少なく、書籍もなければ解説Webサイトも少ない。公式サイトに行ってもほとんどが英語というかなりつらい状況にあります。
↓公式サイト↓
https://developer.mozilla.org/ja/docs/Web/API/WebXR_Device_API
この記事を書いてる時にだいぶ日本語化も進んできてますが、まだ大半は英語という感じがします。慣れてないとかなりツラいのが正直なところです。
英語翻訳もなんだか直訳な感じになってたりしますし、Google翻訳やDeepL翻訳の力を借りながらもある程度は自分の英語力をつけておく必要があると思います。

WebXRに必要なコンポーネントは?

そもそもWebXRに必要なコンポーネントとかってインポートとかincludeとかするの?しないの?って感じになるんですが、これが
そのブラウザが対応していればnavigator.xrオブジェクトがある。ブラウザが対応していない、もしくはXR機器がなければnavigator.xrオブジェクトがnullになる。
ということなので、もういきなり

const XR=navigator.xr;
if (XR) {
    //WebXR対応処理
}else{
   //WebXRが使えない時の処理
}

となります。なお、VR機器はあってブラウザがWebXRに対応しておらずWebVRに対応している場合はPolyfillというものを使用しWebXRエミュレートを行います。このため

<script type="module">
    import {QueryArgs} from "query-args.js"
    import WebXRPolyfill from "webxr-polyfill.module.js";

    if(!navigator.xr){
        if (QueryArgs.getBool('usePolyfill', true)) {
            let polyfill = new WebXRPolyfill();
        }
          (中略)

といったコードを書いて、WebXRPolyfillオブジェクトを生成してからWebXRがある時の処理を書くことになります。
現状すべてがWebXRに対応しているわけではなく、このようなコードを書く必要があります。逆にWebVRが非推奨になりつつあって、今後はWebXR対応で書いておいて、このようになかった時の事を考えてエミュレータ用のコードを書いておくというのがスタンダードな書き方になるようです。

ちなみに上のquery-args.jsやwebxr-polyfill.module.jsはWebXR Samplesから持ってきたものです。GitHubで落とせるのでそこから持って来ましょう。

その他immersive-vr時に使うオブジェクトとしては

  • XRSession : WebXRすべての大元になるオブジェクト
  • XRWebGLLayer : WebGLとの連携を行うためのオブジェクトの型
  • XRReferenceSpace :「参照空間」と言ってVRの時に必要になるもの(これがなかりややこしい)
  • XRRigidTransform : XRReferenceSpaceを更新するときに必要になるオブジェクトの型(これもややこしい)
  • XRViewerPose : ヘッドセットのVR空間内での状態を持つオブジェクトの型
  • XRView : XRViewerPoseから取得できるもので右目左目それぞれに映し出すべき情報(カメラや画角など)を内部に持っている

などがあります。
これらのオブジェクト同士の関係性などがつかめてないとまともにサンプルも理解できないし、まともにプログラミングもできないので、1つ1つのリファレンスは見ておいて、それぞれのオブジェクト(型?)の役割と関係性は最初に何となくでも把握しておいた方がいいです。

ここまでのまとめ

※というわけで、ここまでそも「そもの前提条件が分かっていないとどうなってしまうのか」をお話いたしました。初心者の僕からのアドバイスとしては、事前知識が乏しいままにいきなりWebXR+WebGLでアプリを開発しようとするとかなり苦労しますよという事です。

ちなみにWebXRのサイト
https://developer.mozilla.org/en-US/docs/Web/API/WebXR_Device_API/Fundamentals
には以下のように書かれており

image.png

英語が良く分かりませんが大雑把に解読すると「WebXRアプリを使うのにWebGLを直接扱うのはハードル高いから、Three.jsとかBabylon.jsとかを使ったほうがいいよ」
という事のようで、僕もそう思います。Three.jsやBabylon.jsの中には最初からWebXR用のインターフェース的なものがあり、それを使えば割と簡単にVR表示を行う事ができるからです。
初心者の方で、なおかつ趣味でやろうという方はThree.jsでやるのがおすすめだと思います。

ともかくやるしかない

ここに至るまででかなりの紆余曲折があり、なんとかある程度Javascriptも分ってきてさぁ本格的に組み込むぞというところで、現在の私の開発基盤であるEmscriptenについて軽く説明します。

Emscriptenについて

詳しい事は
https://developer.mozilla.org/ja/docs/WebAssembly/C_to_wasm
を読んでいただくとして、大雑把に説明するとEmscriptenとはC/C++プログラムをコンパイルしてwasmというWebAssemblyファイルとjsファイルを出力してくれるものです。要はC/C++をWebブラウザ上で動かせる状態に変換してくれる仕組みだと思っていただければいいと思います。

となると、WebXR側はJavascript側で記述し、ゲーム側をC/C++側で記述することになるため当然情報の橋渡しや、イベント発行などをやり取りしていかなければなりません。それについても軽く説明します。

JavascriptとC側の関数を呼び出す

基本的にはJavascriptからC側の関数を呼び出すのと、情報(行列やベクトルなど)のやり取りができればよいため、C側からJavascriptの関数を呼び出すところは解説しませんのでよろしくお願いします。

まず、JavascriptからCの関数を呼ぶには

Module.ccall("呼び出したいC側の関数名","戻り値の型",引数の型配列,引数配列);

といった呼び出し方をします。
なお、引数型配列とか引数配列とか言うとりますけれども、引数の数や型は複数あるためこのような形になっています。例えばSetValueという関数でfloatとintを渡すならば

Module.ccall("SetValue","",['float','int'],[3.14,5]);

といった書き方になります。

Javascriptから構造体の情報(メモリの塊)をC側に渡す

さて、ではC側にWebXRがVR機器から取得した値を渡すには「ビューポート」や「ビュー行列」や「プロジェクション行列」が必要になります(しかも右目、左目ぶん)。
片目だけでも4+16+16=36個必要であり、これを引数にするのはちょっときついものがあります。
という事で、もうメモリにまとめて書き込んでそのアドレスを送るという形をとります。そこでJavascript側から、受け渡しに必要なメモリの確保を行う必要があります。それがModule._mallocです。

これは文法的には簡単で

Module._malloc(確保したいサイズ);

でOK。C言語の時と同様、戻り値にそのポインタが返るため

let data=Module._malloc(36*2);

てな書き方になります。なお、解放を忘れると当然メモリリークしますので

Module._free(data);

を忘れないようにしましょう。さて、このアドレスdataに対して値を入れるにはsetValueという関数を用いて

setValue(アドレス, 値, '型');

という感じで一つ一つ値を入れていきます。WebXRからは

  • XRView.transform.inverse.matrixがビュー行列
  • XRView.projectionMatrixがプロジェクション行列

を表します。なおこれらは単なるfloat16個配列なので、ループ等を用いて先ほど確保したメモリに値をひとつひとつ入れていきます(はっきり言って面倒…メモリコピー的なのがあったら便利なのに)

あ、忘れてましたがビューポートも必要でこれも渡さなければいけませんね。ビューポートを取得するには

let viewport = glLayer.getViewport(xrView);

という風に取得します。なお、こいつはx,y,width,heightの構造体状態なので、一つ一つメモリに割り当てていきます。

さてここまでの事を右目用、左目用と行い、確保したデータをいっぱいにします。あとはこの確保したアドレスを引数として渡してあげればいい事になります。型はメモリ全体の型を教えてあげればいいのですが、僕はビューポートもfloatで扱う事で、シンプルに

Module.ccall("SetParameter","",['float'],[data]);

という形で送っています。これ以上書くと長くなってしまいますので、ここに関してはこの辺で切り上げます。詳しくは
https://emscripten.org/docs/porting/connecting_cpp_and_javascript/Interacting-with-code.html
をご覧ください。

さて、ここまでやればあとは実際に組み込んでいくだけですが、色々ハマりポイントがあったので、この後はそれをお話しします。

ハマりポイント

ここからは個別のハマりポイントを書いておきます。1行忘れるだけで挙動がおかしくなる系のものや数学的なものがあります。

makeXRCompatible()を忘れていた

WebXRを使用したグラフィクスを使うには、WebGLのコンテキストをXRが使える状態にしなければなりません。
そのため

WebGLコンテキスト.makeXRCompatible();

といったコードを記述する必要があります。なお、この関数は完了復帰ではなく即時復帰関数なので、awaitつけるなりなんなりしないと、切り替え処理が終わる前に次の処理に進むことになり、うまく動かなくなります。

毎フレームのbindFramebufferを忘れていた

これも忘れるとまともに表示されないどころか、妙な不具合が起こります。ハードやブラウザによってはうまく動いたりするので混乱のもとでした。

どういう事かというと、WebGLの描画先のコンテキストはWebXRから提供されたコンテキストでないとHMDへの表示にバインドされないのです。

で、どうやってそれを取得するかというと、XRSessionからrenderState.baseLayerというものが取得できますので、これをbindFrameBufferとバインドします。Emscriptenの場合はModule.ctx.bindFramebufferを用います。ですから

Module.ctx.bindFramebuffer(Module.ctx.FRAMEBUFFER,XRSession.renderState.baseLayer.frameBuffer);

てな感じで記述する必要があるという事です。

両眼視差と左手系プロジェクションでハマる

これは、普通にWebGL+WebXRではハマらないポイントなので大半の人には関係ない話になりますが、個人的には一番のハマりポイントのため、書いておきます。

VR機器から見える映像は、右目と左目で若干異なる映像が見えており、この差によって人間は「立体感」を得られるのですが、これを両眼視差と言います。

先述したXRView(XRPoseから得られる)には右目用と左目用の「ビュー行列」「プロジェクション行列」が入っており、これを描画時に用いることで仮想空間内で右目から見える映像、左目から見える映像を作り、VR機器の左目や右目のディスプレイに出力します。

はい、ここで自分のアプリでは問題が生じました。それぞれの行列をそのまま適用すると

  • 左右の映像がずれて立体視ができない
  • ポリゴンの裏表が反転する

という問題が発生しました。はじめは理由も意味も分からずああでもないこうでもない、パラメータを調整したりもしましたが、無駄でした。原因はWebXRが右手系であり、自分のアプリでは左手系を採用していたことにありました(※ここはEmscriptenが原因ではなく、自分のアプリ側の採用する座標系に問題があったため通常は気にしなくてOKです)

image.png

という具合に、Z方向が入れ替わることにより、そのままですと「左」と「右」の意味合いが変わってしまいます。これはかなり困りました。

ネットで右手系と左手系のビュー行列、プロジェクション行列を見比べながら、うんうんと唸りながら実際に検証してみて至った結論が(※正しいかどうかはともかく欲しかった結果になったという意味で)

  • プロジェクション行列には手を加えない(ここに手を加えると何をやっても立体視がずれる)
  • ビュー行列のZ成分を反転する

という対処を行い、なんとか欲しい結果を出力することができました。
(もし、もっと適切な対処法をご存じの方がおられましたらご教示をお願いいたします)

フラスタムカリングでもハマる

ゲーム側のプログラムにはフラスタムカリングも搭載されており、ここで持ってきたビュー行列、プロジェクション行列をそのまま使用すると、当然のように結構なオブジェクトが消えてしまいました。

これもしかたがないため、元のプロジェクション行列から、near,far,fovなどを復元し、そこからフラスタムカリングを再計算することで、ただしくゲーム側にフラスタムカリングを適用することができました。

まとめ

はい、ここまでのまとめですが、

  • 初心者はとにかくThree.jsとかから始めましょう。いきなりWebGL+WebXRをやるとかなりしんどいです。
  • 当然ですが、WebXRをやる前にJavascriptについてしっかり理解しておきましょう。
  • 最初から出来上がってるゲームにWebXRを組み込もうとすると座標系などの問題が複雑になるため、可能であればゲームの制作当初からWebXRを組み込んでおいたほうがいいでしょう。

というわけで、とりとめのない話を長々と書きましたが、今後初心者が変なところで躓かないような警告となれば幸いです。
WebXRはまだまだ文献などが少ないため、強い人も、そうでない人も助け合って、盛り上げていきましょう。

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
Sign upLogin
0
Help us understand the problem. What are the problem?