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

posted at

updated at

WebXR APIを直で自作ライブラリに導入してみた

この記事はWebXR ( WebVR/WebAR ) Advent Calendar 2021の最終日25日のコンテンツとしてお送りいたします。

はじめに

@emadurandal です。こんにちは。WebGLライブラリを長年自作している者です。今年もQiitaで活動についてご報告しております。

私のWebGLライブラリRhodoniteでは、WebXR APIを直接呼び出してWebのVRやARに対応しています。

その経験を踏まえ、直でWebXRを実装する際の勘所をお伝えしたいと思います。

全てを紹介すると長くなるので、今回はVRに絞ります。

WebXRの仕様を読む

WebXRはW3CのImmersiveWebワーキンググループが策定しているWebにおけるXR規格です。

前身となったMozilla主導のWebVRをベースにAPIをより汎用化させ、ARにも対応しています。

基本中の基本「WebXR Device API」

WebXRは実質的に多くのAPIから成り立っています。

一番基本となるのがVRヘッドセット等の機器とのやりとりを行う「WebXR Device API」です。

WebXR Device API

image.png

この「WebXR Device API」を中心として、その派生として補助的なAPIが追加で策定されています。

入力関連の「WebXR Gamepads Module」

派生APIの中でも特に重要なのは、この「WebXR Gamepads Module」でしょう。VRヘッドセット等で必ず付属しているコントローラーなどを取り扱うためのAPIであるためです。

WebXR Gamepads Module - Level 1

その他にも、次のような補助APIが策定中です。詳細は @wakufactory さんの記事でより詳しく取り上げられています。

WebVR環境としての OculusQuest (2021) (Qiita WebXR Advent Calendar 2021 5日目)

仕様書なので、初めて見る方は数多くのインターフェース定義の前に圧倒されると思います。

ありがたいことに、ImmersiveWebワーキンググループでは各種のチュートリアルや導入記事を用意してくれています。次章では、それらを参考に実際にWebXRを使ってみたいと思います。

WebXRの初期化

WebXR Device API Explainedというわかりやすい導入ページが用意されています。

WebXR Device APIを利用するためのとっかかりのコードが丁寧に説明されているため、こちらを参考にコーディングを開始するのが最も確実です。

ブラウザがWebXRをサポートしているかチェックする

WebXR Device API Explained の Detecting and Advertising XR Capabilitiesという章で、ブラウザがWebXRをサポートしているかチェックするコードの説明があります。

それを少し修正したコードを以下に記載します。

async function checkForXRSupport() {
  if(navigator.xr == null) {
    console.warn("WebXR API not found");
    return false;
  }
  const supported = await navigator.xr.isSessionSupported('immersive-vr');
  if (supported) {
    const enterXrBtn = document.createElement("button");
    enterXrBtn.innerHTML = "Enter VR";
    enterXrBtn.addEventListener("click", beginXRSession); // 'Enter VR'クリック時に別の箇所に記述したbeginXRSession関数を呼ぶ
    document.body.appendChild(enterXrBtn);
    return true;
  } else {
    console.warn("Session not supported: " + reason);
    return false;
  }
}

コードを見ると分かる通り、最近のブラウザ環境ではお馴染みのnavigatorオブジェクトにxrというプロパティが生えており、WebXR APIではこのnavigator.xrがAPI利用の起点となります

上記のcheckForXRSupport関数はasyncがついているように非同期関数ですので、呼び出し側もasync関数内である必要があります(ただし、グローバル空間の場合は省略できる場合があります: Top level awaitについて整理した(「at backyard」ブログ様))。

async readyXR() {
  const isXrSupport = await checkForXRSupport();
  ...
}

このコード(readyXR関数)をいつ実行するかですが、XR機器を検知したい頃合いになったらすぐ呼んで良いでしょう。
例えばインタラクティブコンテンツの最初の起動時でもいいですし、アプリケーションのXR開始のための導線UIが表示されるタイミングでもいいかもしれません。

navigator.xr.isSessionSupported('immersive-vr');を呼ぶと、その時点でブラウザがユーザーにWebXRの利用許可を求めます。ユーザーが許可しなければ、そこでconsole.warn("Session not supported: " + reason);が実行されます。許可すれば上記例題コードではbeginXRSession関数が呼ばれ、さっそくXRセッション(VRモード)に突入します。

私のライブラリRhodoniteでも同様のWebXR準備処理があります。

公式のオンラインエディタであるRhodonite Editorでは、ユーザビリティの方針(非VRでの閲覧・編集作業が主な使い方であり、VRは補助である)上、WebXR有効がわかったからといってすぐにWebXRモードに移るのではなく、間にユーザー操作を挟んでいます。

  1. WebXRのUIが表示された時にreadyXR()相当の処理を行い、WebXRのサポートを調べる。非サポートの場合はWebXR切り替えボタンをDisable状態にしてONにできないようにする。
  2. UIの中のWebXR切り替えボタンをユーザーが明示的に押した際にbeginXRSession()相当の処理を行い、VRモードに突入する


image.png

ちなみに以下のVRタブからWebXR VRモードに移れますので、是非お試しください。

image.png

セッションを要求する

次のWebXR Device API Explained の Request a Sessionでは、navigator.xrオブジェクトからXRSessionオブジェクトを取得するステップについて解説しています。

XRSessionは、その時に起動したXRモードの状態を一手に保持するオブジェクトのことで、WebGLで例えるならWebGLRenderingContext(glオブジェクト)に相当するものと考えてください。

UA(User Agent)全体で同時に許可されるXRモード(正確な用語としてはImmersiveセッション)は、XRハードウェアデバイスごとに1つだけです。

// 前述のコードにより、この関数は"Enter VR"ボタンをクリックすると呼ばれます。
let xrSession = null;
let xrReferenceSpace = null;

async function beginXRSession() {
  try {
    // セッションを後々使えるように外側のスコープ変数に保存します。
    xrSession = await navigator.xr.requestSession('immersive-vr');
    // XR参照空間を取得し、後々使えるように外側のスコープ変数に保存します。
    xrReferenceSpace = xrSession.requestReferenceSpace('local');
    // WebGLと連携するためのXRWebGLLayerを作成します。
    await setupWebGLLayer();
    // XR用のレンダリングループを回します。
    xrSession.requestAnimationFrame(XRRenderingLoop);
  } catch(err){
    // VRモードに入ることに失敗したため、通常のレンダリングループに復帰します。
    window.requestAnimationFrame(nonXRNormalRenderingLoop);
  }
  ...
}

function onDrawFrame(timestamp, xrFrame) {
  // アクティブなXRセッションか?
  if (xrSession) {
    // XRとしての描画
    ...
    WebGLDraw(); // 任意のWebGL描画
    ...
    xrSession.requestAnimationFrame(onDrawFrame);
  } else {
    // XRでない通常の描画
    window.requestAnimationFrame(onDrawFrame);
  }
}

RhodoniteでのbeginXRSession()相当の処理を以下に示します。

XRWebGLLayerを準備する

次のWebXR Device API Explained の Setting Up an XRWebGLLayerでは、WebGL描画と連携するためのWRWebGLLayerを準備するステップについて解説しています。

gl.makeXRCompatible();はWebXRにおけるおまじないのようなもので、WebGLRenderingContextをWebXR機器で使用する際に互換性が問題ないことを確認するために呼びます。
次に、XRWebGLLayerというレイヤーオブジェクトを作成し、xrSessionにレンダーステートとして設定します。これは、XRデバイスの表示物の管理でレイヤーという構造があり、XRWebGLLayerはWebGLによる描画をXRデバイスに表示させるためのレイヤーです。未確認ですが、これがWebGPUの場合はXRWebGPULayerになるのかもしれません。

const glCanvas = document.createElement("canvas");
const gl = glCanvas.getContext("webgl");
loadSceneGraphics(gl);

async function setupWebGLLayer() {
  // WebGLレンダリングコンテキストがXRデバイスとの互換性があることを確認します。
  await gl.makeXRCompatible();
  // XRWebGLLayerというWebGL描画結果をXRデバイスに表示するた目のレイヤーを作成し、xrSessionにレンダーステートとして設定します。
  xrSession.updateRenderState({ baseLayer: new XRWebGLLayer(xrSession, gl) });
}

Rhodoniteでの対応する処理を以下に示します。

VR空間を視点が動けるようにする

WebXRの初期化が終わったら、いよいよVR空間上を動き回れるようにしましょう。
まずはWebXR用のレンダリングループを作ります。

WebXRにおけるレンダリングループ

function onDrawFrame(timestamp, xrFrame) {
  // XR中であることの判定?
  if (xrSession) {
    let glLayer = xrSession.renderState.baseLayer;
    let pose = xrFrame.getViewerPose(xrReferenceSpace);
    if (pose) {
      // 3Dライブラリ・エンジンとしてのアップデート処理(キャラクターアニメーション、物理シミュレーション等、描画の前に処理すべきこと)
      scene.updateScene(timestamp, xrFrame);

      // glLayerはWebGLFramebufferを持っているので、それを描画対象としてバインドする。
      gl.bindFramebuffer(gl.FRAMEBUFFER, glLayer.framebuffer);

      // 複数のビュー(多くの場合、右目と左目)について
      for (let view of pose.views) {
        // ビューポート設定を行い
        let viewport = glLayer.getViewport(view);
        gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height);
        // XRコンテンツの描画を行う
        drawScene(view);
      }
    }
    xrSession.requestAnimationFrame(onDrawFrame);
  } else {
    // No session available, so render a default mono view.
    gl.viewport(0, 0, glCanvas.width, glCanvas.height);
    drawSceneFromDefaultView();

    // Request the next window callback
    window.requestAnimationFrame(onDrawFrame);
  }
}

XRFrameオブジェクトには現在のレンダリングフレームに関する情報が入っています。このオブジェクトから現在のフレームにおけるXRViewerPose取得できます。このオブジェクトには、XRデバイスにシーンを正しく表示するためにすべてのビュー(右目や左目など)が含まれます。

描画ライブラリのカメラオブジェクトにWebXRViewerPoseの位置情報を設定し、ライブラリで描画する

function drawScene(view) {
  // cameraは描画ライブラリ独自のカメラオブジェクトとする
  camera.setPositionVector(
    view.transform.position.x,
    view.transform.position.y,
    view.transform.position.z,
  );

  camera.setOrientationQuaternion(
    view.transform.orientation.x,
    view.transform.orientation.y,
    view.transform.orientation.z,
    view.transform.orientation.w,
  );

  camera.setProjectionMatrix4x4(
    view.projectionMatrix[0],
    view.projectionMatrix[1],
    //...
    view.projectionMatrix[14],
    view.projectionMatrix[15]
  );

    // 描画ライブラリの描画関数で描画を行う
  scene.renderWithCamera(camera);
}

ここまで実装すれば、コントローラなしの自分視点のみですが、VR対応ができるはずです。色々とVR空間を動き回ってみてください。

XRの終了処理

WebXR Device API Explained の Ending the XR Sessionでは、XRの終了処理について解説があります。

本記事ではWebXRに初挑戦したい方に向けた処理ですので、ワクワクすること重視で書いています。紙面の長さの都合もあるので今回は触れるだけで留めますが、ライブラリやアプリケーションとしてユーザーに提供する場合、終了処理も必要ですので怠らないようにしてください。

コントローラーに対応する

VRに対応するのなら、やはりコントローラーも使えるようにしたいものです。

そのためのAPIが「WebXR Gamepads Module」、VRヘッドセット等に付属しているコントローラーを取り扱うためのAPIです。

WebXR Gamepads Module - Level 1

例によって仕様書だけではどこから手をつけていいか途方に暮れてしまいますね。

コントローラー対応のための支援プロジェクト「WebXR Input Profiles」

ありがたいことに、ImmersiveWebワーキング&コミュニティグループは、コントローラー(入力機器)のためのサンプルプロジェクトと解説ページを公開してくれています。

WebXR Input Profiles

image.png

このページはwebxr-input-profilesというGithubプロジェクトの内容を解説しています。対応するGithubリポジトリはこちらです。

このプロジェクトは、WebXRのコントローラーで主要な製品のものをその3D形状データも含め、利用しやすい形でテンプレートを提供してくれるものです。

Packagesの章で、4つのサブプロジェクトについて示されています。

  • registry: WebXR Device APIから取得したUser Agents情報から、それがどの会社のVR製品の特定の型番モデルかをスマートに検索・取得・抽象化するためのJSONメタデータを提供しています。
  • assets: 各社のVRヘッドセットに付属するコントローラーの3DモデルをglTFファイル(.glb)として提供します。
  • motion-contorollers: コントローラーの各入力部品からの入力データを、統一されたインターフェースで扱うための仕組みを提供します。
  • viewer: 上記3つを組み合わせた実例デモビューアーです。 コントローラーをVR空間の中で適切に動かしたり、ユーザーの操作に合わせて、コントローラーのボタンやサムスティック、トリガーなどがVR空間内でも実際に押されたかのように適切に動くようにするための仕組みを提供します。

Viewerサブプロジェクト

ページ中のProfile Validator and Viewerリンクをクリックすると、各種メジャー製品のコントローラーの3D形状モデルを鑑賞できるWebXRデモが起動します。

image.png

そうです。ImmersiveWebワーキング&コミュニティグループは、OculusやHTCViveなど、主要なVRヘッドセット製品のコントローラーの3DモデルまでOSSで提供してくれているのです。これで自分でBlenderで慣れないコントローラーポリゴン制作なんてしないですみますね。

Assetsサブプロジェクト

それらの3DアセットはWebXR Input Profiles - Assetsにあります。Gitリポジトリの各ディレクトリの中に、主要各社のコントローラーのglTF 3Dモデルがバイナリ形式(.glb)で格納されています。

そして、このリポジトリのコードベースを調べるとわかるのですが、コントローラーのハンドリングを非常に巧みに行う仕組みが組まれています。

例えば、AssetsサブプロジェクトのGithubリポジトリを見てみると、profilesフォルダの下に各VR製品のコントローラーの3Dモデルファイルが格納されています。Oculus touch v3(Oculus Quest 2のコントローラー)の場合はこんな感じ。左手用コントローラーと右手用コントローラー。そしてメタデータのprofile.jsonが並んでいます。

image.png

ではここで左手用のleft.glbを(僭越ながら私のRhodoniteEditorで)を表示してみましょう

Oculus touch v3(Oculus Quest 2のコントローラー)が表示されました。また、左側を見ると、このアセットデータはこのような階層構造になっています。

image.png

コントローラー本体と、本体に設置されている各種ボタンやトリガー、サムスティックが、オブジェクトとしては分かれていますね。そして各ボタン等はさらに以下の規則でまたオブジェクトとして分かれています。

例えば、次のキャプチャ画像はコントローラーの入力部品でsqueezeと呼ばれるサイドボタンのようなボタンなのですが、それに関連するオブジェクトは次の4つあります。

  • xr_standard_squeeze_pressed_max
  • xr_standard_squeeze_pressed_min
  • xr_standard_squeeze_pressed_value
    • squeeze

image.png

他の入力部品も見たところ同様の構造のようです。つまり一般化すると次のような規則になり、それぞれの役割を持っています。

オブジェクト 役割
xr_standard_<入力部品種別名>_pressed_max 3D形状を持たない空オブジェクト。入力部品の全く押されていない状態の位置座標を保持する。
xr_standard_<入力部品種別名>_pressed_min 3D形状を持たない空オブジェクト。現在の入力部品の完全に押し込まれた状態の位置座標を保持する。
xr_standard_<入力部品種別名>_pressed_value 3D形状を持たない空オブジェクト。現在の入力部品の押し込まれ具合を反映した相対位置座標(値域は[0,1])を保持する。
<入力部品種別名> 実際の入力部品の3D形状を保持

なぜこのような込み入ったオブジェクト構造をとっているのでしょうか。それは、VRをプレイするユーザーによるこれら入力部品の押し込み具合に応じて、VR上でも押された入力部品の座標位置を下げる実装を簡単にするためです。

この構造を巧みに使ったコードサンプルが先ほど紹介したViewerサブプロジェクトだったのです。

image.png

わかりますでしょうか? ようは、xr_standard_<入力部品種別名>_pressed_valueから現在の入力部品の押し込み具合(値域は[0,1])を取得し、その相対値を使ってxr_standard_<入力部品種別名>_pressed_maxxr_standard_<入力部品種別名>_pressed_minの位置座標を線形補完することで、押し込み具合を反映した位置座標を簡単に得ることができる、ということです。つまり、次の式で求められるわけですね

実際の位置座標 = lerp(xr_standard_<入力部品種別名>_pressed_min, xr_standard_<入力部品種別名>_pressed_max, xr_standard_<入力部品種別名>_pressed_value)

サムスティックの場合は、回転を表すクォータニオンのminとmaxを現在のvalueでslerp(球面線型補間)します。考え方は全く同じです。

私のRhodoniteライブラリでも、ほぼ同様のやり方をしています。

image.png

Motion Controllersサブプロジェクト

Motion Controllersサブプロジェクトは、前述のViewerの仕組みを実現するための補助ライブラリといえます。

WebXRでは、入力部品による入力データーはXRInputSourceオブジェクトに入っています。このオブジェクトから取れるデータをMotion Controllersサブプロジェクトでは独自のクラスに入力データをうまく整理して格納し、その統一されたインターフェースでプログラミングしやすくしています。

WebXR Input Profiles - Motion Controllers

Registryサブプロジェクト

WebXR Device APIから取得したUser Agents情報から、それがどの会社のVR製品の特定の型番モデルかをスマートに検索・取得するためのJSONメタデータを提供しています。

ベンダー情報だけでなく、それらの特定の型番モデルごとに、サポートされているボタンの種類や数などの情報もJSONに格納されています。

image.png

これも前述のViewerサブプロジェクトで活用されており、これのおかげで全てのベンダーのVR製品・型番の際をうまく抽象化した上でプログラミングできているというわけです。

自分のライブラリで「webxr-input-profiles」プロジェクトを活用してみる

先ほどのViewerサブプロジェクトではVRコントローラーをデモするために、非常に優れた仕組みを導入していました。
同等のものを車輪の再開発するより、これを直接使わせてもらうのが楽です。

Rhodoniteでも利用させていただいています。ただ、当時のmasterではうまく動作しない部分があったため、フォークして一部改修をおこなった自分のバージョンを使っています。

image.png

image.png

他の補助プロジェクトを活用する

ImmersiveWebでは他にも多くの補助プロジェクトを公開しています。

WebXRを加速させるためのWebGL拡張

これについては、今年のRhodoniteの記事の以下の箇所に説明を回しました(本記事をこれ以上長くすると読みづらくなるので)

最後に

 今回は誌面の都合上、内容がWebXR APIの直接の利用法や入力処理の紹介にとどまり、本来ご紹介したかった自分のライブラリでWebXRを導入する上で学んだ知見(組み込みで苦労したことや新たな発見等)のご紹介まで至れませんでした。相変わらずの内容構成力の甘さを痛感します。

 いずれにしても、WebXR対応はImmersiveWebが多くの導入情報や補助プロジェクトを公開してくれているため、慣れは必要ですが、本質的にはそれほど難しいわけではありません。

 自分がコーディングしたもので独自のVR体験を作れるというのは、実際やってみると格別の味わいと面白みがあります。皆さんも生WebXRプログラミングにぜひ挑戦してみてください。

 WebARについては、また別の機会に記事を書きたいと思います。

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