ククログ

株式会社クリアコード > ククログ > > OpenArm用のWebXRベースのVR遠隔操作アプリの試作

OpenArm用のWebXRベースのVR遠隔操作アプリの試作

オープンソースのロボットアームであるOpenArmの開発をお手伝いしている須藤です。Meta Quest 3PICO 4といったVRデバイスを使ってOpenArmを遠隔操作したいというニーズがあります。現在はMeta Quest 3用の遠隔操作アプリを提供していますが、PICO 4でも遠隔操作したいという要望やらなんやらがあるのでWebXRベースの遠隔操作アプリを試作しました。今回はじめてWebXRを触ったのですが、いろいろ学んだことがあったのでまとめておきます。

WebXR

WebXRはWebブラウザー上でVR(仮想現実、Virtual Reality)やAR(拡張現実、Augmented Reality)を実現するためのJavaScript用のAPIです。

VRやらARやらをまとめてXRと呼んでいます。多くのXRアプリケーションは3Dのなにかを描画しますが、OpenArmの遠隔操作アプリはVRデバイスのコントローラー(Meta Quest 3ならこういう手に持つやつ)でOpenArmを動かしたいだけなので3Dのなにかは描画しません。(将来的にはOpenArmについているカメラの画像とかを描画したくなるかもしれませんが、現時点では描画しません。)VRデバイスのコントローラーの情報をリアルタイムで取得できれば十分です。

WebXRでは3Dのなにかの描画もVRデバイスのコントローラーの情報の取得もできますが、今回は後者のAPIだけを使いました。

https://developer.mozilla.org/ の情報とW3CのImmersive Webワーキンググループが提供するサンプルと実機での挙動を参考にしながら開発を進めました。

WebXRを使うために必要なこと

いくつか動かすまでに知らないといけなかったことがあったので、まずはそれらについてまとめます。

HTTPS

WebXRを使うためにはHTTPSが必要です。

手元で動作確認する場合、ruby -run -e httpd -- --bind-address 0.0.0.0などでローカルにWebサーバーを立ち上げてJavaScriptを配信することが多いと思いますが、これではWebXRを使えません。HTTPSではなくHTTPだからです。

私は最初は既存のHTTPSサーバーを使って実験していました。具体的には https://www.clear-code.com/~kou/ に置いて、サーバー上のJavaScriptファイルを編集しては再読込して実験していました。しかし、やはりローカルで完結した方が便利なのでだいたいわかってきてからはローカルにHTTPSのWebサーバーを立ち上げて実験するようになりました。

ローカルでHTTPSのWebサーバーを立ち上げる場合、自己署名証明書を作ってそれを使うことになります。当然、VRデバイスのWebブラウザーはこの証明書を信用していないのでアクセス時にWebブラウザーは警告を表示します。この際、上級者向けオプションとして警告を無視してアクセスすることができます。アクセスしている先は自分が認識しているはずなのでローカルでの開発時はこれで問題ありません。このようにアクセスしてもWebXRは有効になります。

自己署名証明書を用意するのは少し面倒ですが、私はApache Arrow Flight SQL adapter for PostgreSQLを開発したときに便利スクリプトを用意しておいたので楽々でした!こんなこともあろうかと!

HTTPSを使う場合は、必ずホスト名を使わないといけません。IPアドレスでの直接アクセスではHTTPSを使えません。(証明書のCNをIPアドレスにすると使えるの?)最近のLinuxマシンにはAvahiがインストールされていることが多いので、なにもしなくても${HOST_NAME}.localでローカルネットワーク内で名前解決できるホスト名を得られます。自己署名証明書を用意するときにVRデバイスから名前解決できるホスト名が必要ですが、${HOST_NAME}.localを使えます。

細かいことは試作したアプリのREADMEに書いておいたので具体的な手順を知りたい人はそちらをどうぞ。

自己署名証明書を生成したらruby -run -e httpd -- --bind-address 0.0.0.0 --ssl-certificate=server.crt --ssl-private-key=server.key --port=8443とかでHTTPSなWebサーバーを起動できます。

開始時の明示的なユーザーのアクション

WebXRには「セッション」という概念があり、XRを始めるというのはセッションを開始することになります。コードでいうとこういう感じになります。

if (navigator.xr) {
  // WebブラウザーがWebXRをサポートしていればnavigator.xrに
  // XRSystemオブジェクトが入っている。

  // セッションを開始。
  navigator.xr.requestSession(...);
}

しかし、これは動きません!なぜなら明示的にユーザーがXRを始めるというアクションをしていないからです。詳細はUser intentを参照してください。

では、どうするかというと、たとえば、ボタンを作ってボタンをクリックしたらセッションを始めるというようにします。コードで言うとこういう感じになります。

<button id="start">Start</button>
if (navigator.xr) {
  // WebブラウザーがWebXRをサポートしていればnavigator.xrに
  // XRSystemオブジェクトが入っている。

  document.
    getElementById("start").
    // クリックされたらセッションを開始。
    addEventListener("click", () => {navigator.xr.requestSession(...);});
}

Immersive Webのサンプルにすべてボタンがあるのはどうしてだろうと思っていたのですが、こういうことだったんですねぇ。

immersive-arのためにはXRSession.updateRenderState()XRSession.requestAnimationFrame()を設定しないといけない

XRSystem.requestSession()にはmodeを指定します。modeにはimmersive-ar(没入型の拡張現実)などを指定できます。没入型の拡張現実を指定するとVRデバイスで見えていたWebブラウザーなどは消えて、「VRデバイス越しに見える現実のまわりの様子」と「WebXRで描画する3Dのなにか」が統合されて表示されます。

前述の通り、今のところ、OpenArm用のVR遠隔操作アプリは3Dでなにかを描画する必要はなく、VRデバイスのコントローラーの情報だけが必要なので、没入型の拡張現実モードにして、「VRデバイス越しに見える現実のまわりの様子」だけを表示しています。VRデバイスなしのときよりも見づらくなるのですが、これでOpenArmの様子やパソコンの画面も見えるようになります。

WebXRでなにも描画しないので、描画関連の設定をしなくても動くと思ったのですが、そうではありませんでした。少なくとも次の2つの設定をしなくてもいけません。

XRSession.updateRenderState()は次のように設定すれば十分です。

const canvas = document.createElement("canvas");
const gl = canvas.getContext("webgl", { xrCompatible: true });
session.updateRenderState({ baseLayer: new XRWebGLLayer(session, gl) });

XRSession.requestAnimationFrame()は次のように設定すれば十分です。コールバック中でなにもしなくてもよいのですが、一度しか呼ばれないので、コールバック中で自分自身を再度登録して何度も呼ばれるようにします。これで、現実のまわりの様子が表示され続けます。

function onFrame(time, frame) {
  session.requestAnimationFrame(onFrame);
}
session.requestAnimationFrame(onFrame);

ということで、没入型の拡張現実を動かすために必要最小限のコードは次のようになります。

if (navigator.xr) {
  // WebブラウザーがWebXRをサポートしていればnavigator.xrに
  // XRSystemオブジェクトが入っている。

  function onSessionStart(session) {
    // updateRenderState()を呼ばないと描画されない
    const canvas = document.createElement("canvas");
    const gl = canvas.getContext("webgl", { xrCompatible: true });
    session.updateRenderState({ baseLayer: new XRWebGLLayer(session, gl) });

    function onFrame(time, frame) {
      // 何度も呼ばないと描画され続けない
      session.requestAnimationFrame(onFrame);
    }
    // requestAnimationFrame()を呼ばないと描画されない
    session.requestAnimationFrame(onFrame);
  }

  function onStart() {
    // ユーザーのアクションがあったらセッションを開始。
    navigator.xr.requestSession("immersive-ar").then(onSessionStart);
  }

  // WebXRを始めるためにはユーザーのアクションが必要。
  document.getElementById("start").addEventListener("click", onStart);
}

WebXRのデバッグ方法

動くようになるまでいろいろ試行錯誤しました。いくつかデバッグに役立つ情報をまとめます。

AndroidのスマホもWebXR対応

最初はパソコンでコードを編集して、VRデバイス(PICO 4)を装着してVRデバイスのWebブラウザーでアクセスして動かして、VRデバイスを外してまたパソコンでコードを編集して、ということを繰り返していました。しかし、VRデバイスの着脱が面倒でした。

試しに手元のAndroidのスマホのGoogle Chromeで https://www.clear-code.com/~kou/ にアクセスしてみたところ、WebXRに対応していました。VRデバイスのコントローラー相当のものはないのですが、没入型の拡張現実が動くかどうかまでのテストには便利でした。物理的な着脱が不要だったからです。

パソコン上のGoogle Chromeで動くMeta Quest 3のエミュレーターがある

Meta Questの開発者向けのドキュメントにはGoogle Chromeで動くエミュレーターImmersive Web Emulatorがあると書いてありました。

たしかにこれを使うとパソコン上でもWebXRが動きます。Androidとは違ってVRデバイスのコントローラー相当のものもあります。コントローラーの移動はマウスでも簡単に操作できますが、手先の回転を操作するのは難しいです。それでも、パソコン上で操作できる(物理的な着脱なしで試行錯誤できる)ことはすごく便利です。

特に便利なのは、Google Chromeの開発者コンソールを使えることです。console.log()でデバッグプリントできます。

挙動がちょっとわからないところがあって、それは、ページ読み込み直後はnavigator.xr.isSessionSupported("immersive-ar")の結果がfalseになるところです。おそらく、ページ読み込み直後になにか初期化処理をしていてそれが一通り終わったらtrueを返すようになるのだと思いますが、詳細は調べていません。

doraノードとして動かす

OpenArmのdoraベースのデータ収集・推論データフローの設計でまとめた通り(この話を書くためにまとめたのです!)、OpenArmのデータ収集機能はdoraベースで実現しています。

既存のMeta Quest 3用VR遠隔操作doraノードWebXRベースのVR遠隔操作doraノードに置き換えるだけで、データフローの他の部分はそのまま再利用できれば設計の狙い通りというわけです。ということで、このWebXRアプリもdoraノードとして動くようにします。

doraノードとして動かすためにはWebサーバーとして動きつつdoraノードとしても動かないといけません。Webサーバー部分はFastAPIUvicornで実現できるので、それらをdoraノード内で動かせばよいです。

一般的なdoraノードは次のようなループを回すことになります。

import dora

def main():
    node = dora.Node()

    # doraからイベントが渡ってこなくなると勝手にループが終了する
    for event in node:
        pass

if __name__ == "__main__":
    main()

一方、Uvicornも次のようなループを回すことになります。

from fastapi import FastAPI
import uvicorn

app = FastAPI()

def main():
    config = uvicorn.Config(app)
    server = uvicorn.Server(config)
    server.run()

if __name__ == "__main__":
    main()

同期的な2つのループを同時に回すことはできないので、工夫しないといけません。このdoraノードではUvicornのループは普通に回して、doraのループはポーリングに変えました。このdoraノードでは入力を扱わないからです。ポーリングでもそれほど遅延は気になりません。

import asyncio
import dora
from fastapi import FastAPI
import uvicorn

app = FastAPI()
server = None
node = None

async def _main_dora():
    while not server.should_exit:
        if node.is_empty():
            await asyncio.sleep(0.001)
            continue
        event = node.next()
        if event["type"] == "STOP":
            break
    server.should_exit = True

async def _main_async():
    task_uvicorn = asyncio.create_task(server.serve())
    task_dora = asyncio.create_task(_main_dora())

    await task_uvicorn
    await task_dora

def main():
    config = uvicorn.Config(app)
    global server
    server = uvicorn.Server(config)
    global node
    node = dora.Node()

    asyncio.run(_main_async())

if __name__ == "__main__":
    main()

VRデバイスのコントローラーの情報をdoraデータフローに流す

Webサーバーをdoraノードとして動かすことができるようになったので、あとはWebXRでVRデバイスのコントローラーの情報を取得してdoraデータフローに流せばよいです。

WebXRでVRデバイスのコントローラーの情報を取得するにはいろいろなアプローチがありそうですが、XRSession.requestAnimationFrame()のコールバック内で取得する方法を選びました。できるだけリアルタイムで情報を取得したいのと、「コントローラが動いたら発行されるイベント」のようなそのもののイベントがなさそうだったからです。

今のOpenArmのVR遠隔操作処理では、VRヘッドセットからコントローラーへの相対位置を使っています。WebXRでその情報を取得するにはXRSession.requestReferenceSpace()XRFrame.getPose()を使います。

XRSession.requestReferenceSpace("viewer")でVRヘッドセットからの相対位置を取得するためのXRReferenceSpaceを取得し、それをXRFrame.getPose()に渡してコントローラーの相対位置を取得します。

session
  .requestReferenceSpace("viewer")
  .then((space) => {
    function onFrame(time, frame) {
      // 最初の入力ソースがコントローラーの場合
      const source = session.inputSources[0];
      // VRヘッドセットからのコントローラーへの相対位置を取得
      const pose = frame.getPose(source.gripSpace, space);
      session.requestAnimationFrame(onFrame);
    }
    session.requestAnimationFrame(onFrame);
  });

これをdoraノードに渡します。今回はWebSocketを使いました。

サーバー側はこうです。

from fastapi import WebSocket, WebSocketDisconnect
import json

@app.websocket("/websocket")
async def _websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    try:
        while not server.should_exit:
            data = await websocket.receive_text()
            response = json.loads(data)
            # 本当はここでいい感じに整える
            node.send_output("pose", pa.array(response["pose"], type=pa.float32()))
        await websocket.close()
    except WebSocketDisconnect:
        pass

JavaScript側はこうです。

if (navigator.xr) {
  let websocket = new WebSocket("wss://" + location.host + "/websocket");
  // ...
  function onFrame(time, frame) {
    // 最初の入力ソースがコントローラーの場合
    const source = session.inputSources[0];
    // VRヘッドセットからのコントローラーへの相対位置を取得
    const pose = frame.getPose(source.gripSpace, space);
    const response = [
      pose.transform.position.x,
      pose.transform.position.y,
      pose.transform.position.z,
      pose.transform.orientation.x,
      pose.transform.orientation.y,
      pose.transform.orientation.z,
      pose.transform.orientation.w,
    ];
    websocket.send(JSON.stringify(response));
    session.requestAnimationFrame(onFrame);
  }
  session.requestAnimationFrame(onFrame);
  // ...
}

これで60HzくらいでVRデバイスのコントローラーの状態をdoraデータフローに流せます。

本当はもう少しゴニョゴニョやっていますが、大きな流れはこんな感じです。

WebXRベースのVR遠隔操作doraノードのサンプルはOpenArm実機がなくても動くようになっています。MuJoCoという物理シミュレーター上でOpenArmを動かすようになっているからです。(これも、OpenArmのノード部分をMuJoCoのノードに切り替えて他のノードは再利用して実現できています。)

VRデバイスがなくてもImmersive Web Emulatorを使えば実行できます。興味が出てきた人は動かしてみてください。

まとめ

まだ試作段階でまだ使い物になるか判断できていない状態ですが、WebXRベースのVR遠隔操作doraノードを作ったのでWebXR関連の学びをまとめました。

既存のVR遠隔操作doraノードは、Meta Quest 3側で動くアプリ部分の扱いが難しそう(Unityがオープンソースではないのでソースコードやアプリの公開がちょっと面倒そうなのと、Unity環境を用意しないと開発ができないのが面倒そう)なので、代替方法としてWebXRを検討してみています。WebXR版はWebXRにしか依存していないのでオープンソースとしての公開もわかりやすいですし、いろんなデバイスに対応できそうですし、特別な開発ツールがなくても変更できるので、コントリビューターが増えやすいのではないかという期待があります。

手先を思ったように動かせないのでチューニングの必要がすごくありそうなのですが、動くようになったので試してみてください。WebXRが得意な人からのコントリビュートを待っています!まだ、WebXR版でいくぞ!となったわけではないですが。。。