[Three.js #29] Three.js + MMD を VR対応する — WebXRで躓いた点まとめ

はじめに

前回は、Three.js 製時計アプリに Wallpaper / BackWall UI を追加し、背景の見た目を切り替えられるようにしました。

今回はその続きとして、Next.js + Three.js + MMD の時計アプリを Quest 2 単体ブラウザで動かすための WebXR 対応 を進めました。

すでに過去記事でも Three.js の WebXR 実装には触れていますが、今回はゼロから組み直した前提で、実際に躓いたポイントを中心に ざっと整理しておきます。

前回記事:

スクリーンショット:

動画(Youtube):

動画(VR):

今回やったこと

今回の到達点は次の通りです。

  • Next.js 開発サーバーを HTTPS で起動
  • Quest 2 単体ブラウザからアクセス可能に
  • Three.js の WebXR 起動確認
  • MMD 表示成功
  • VR 中の移動対応
  • MMD の会話時に、ユーザー方向を向く処理を修正
  • Quest Browser で使いづらかった色変更 UI を自前実装に置き換え

見た目以上に、今回は環境依存の落とし穴が多い回でした。

まず最初の壁は HTTPS

Quest 2 単体ブラウザで WebXR を動かす場合、まず重要になるのが HTTPS です。

PC ブラウザで普通に開発していると、localhost ベースの確認に慣れてしまいますが、Quest 2 実機からアクセスして WebXR を使う場合は、その感覚のままだとそのまま詰まります。

今回の Next.js 側の対応はかなり単純で、package.json の開発スクリプトを以下のように変更しました。

"dev": "next dev --experimental-https -H 0.0.0.0"

これで npm run dev だけで HTTPS 起動できるようになり、Quest 2 側から LAN 内の PC にアクセスして確認可能。

crypto.randomUUID() 問題

次に引っかかったのは、MMD 読み込みまわりで使っていた crypto.randomUUID() です。

PC ブラウザでは気付きにくかったのですが、Quest 2 のブラウザ環境ではこの前提が崩れ、処理の途中で止まる場面がありました。

そこで、UUID 前提をやめて単純な ID 生成関数に差し替えました。

function makeId() {
  return "id_" + Math.random().toString(36).slice(2) + Date.now().toString(36);
}

こういうところは、ローカル PC 上だけで見ていると問題が見えにくい部分です。 実機対応では、便利 API に少し依存しすぎない方が強いと改めて感じました。

Quest 2 単体でのデバック方法

Quest 2 単体ブラウザでデバッグしていると、PC ブラウザのように手軽にコンソールを見続ける前提が取りづらいです。

そこで今回は、進行状況やエラーを画面内ログとして直接表示する仕組みを入れました。

function log(msg) {
  console.log(msg);

  const el = document.getElementById("debug");
  if (el) {
    el.textContent += msg + "\n";
  }
}

これはかなり効きました。

「ログはコンソールで見るもの」と思い込むと、実機デバッグ時に一気に苦しくなります。 でも実際には、ログはただのテキストです。 ならば画面に出してしまえばいい、というだけの話でした。

WebXR のように確認環境が限られる場面では、画面内ログはかなり強いです。

VR 中の視線追従がズレる問題

今回もっとも厄介だったのはここです。

PC ブラウザでは、MMD が会話中にこちらを向いて吹き出しを出す処理は普通に動いていました。 しかし Quest 2 の VR で見ると、キャラクターがこちらではない方向を向いたり、原点付近を見続けたりしていました。

最初は faceCameraSmooth() 側の計算ミスかと思いました。 camera.position を見るのか、renderer.xr.getCamera() を見るのか、lookAt() の基準が逆なのか、いろいろ疑いました。

ただ、最終的に本質だったのはそこではなく、そのフレームの最新 HMD pose を、いつ参照しているか でした。

renderer.xr.getCamera() の問題

Three.js の WebXR では、renderer.xr.getCamera(camera) を使って XR 用カメラを取得できます。 なので最初は、「これを見れば直るだろう」と考えました。

ところが、今回のケースではそれでもダメでした。

問題は更新順です。

アニメーションループの中で、

  1. updateVRMove(delta)
  2. updateMMD(delta)
  3. renderer.render(scene, camera)

という順に処理していたため、updateMMD() の時点では、そのフレームの最新 HMD pose がまだ scene 側に十分反映されていない 状態になっていました。

その結果、MMD 側は「プレイヤーを見ているつもり」で、実際には古い座標や基準位置寄りのカメラを見ているような挙動になっていました。

体感としては、まさにゴーストカメラを見ている感じでした。

  • 頭を動かしても反応しない
  • スティック移動すると少しだけ向きが変わる
  • でも本当に見てほしい位置は見ていない

この症状はかなり混乱しやすかったです。


解決策は animate(time, frame) の getViewerPose()

最終的には、setAnimationLoop() のコールバックで受け取れる frame から、直接 viewer pose を取得する形で解決しました。

function animate(time, frame) {
  if (renderer.xr.isPresenting && frame) {
    const refSpace = renderer.xr.getReferenceSpace();
    const pose = frame.getViewerPose(refSpace);

    if (pose) {
      const p = pose.transform.position;
      xrViewerWorld.set(p.x, p.y, p.z);
      player.localToWorld(xrViewerWorld);
    }
  }

  updateMMD();
  renderer.render(scene, camera);
}

このようにして、そのフレームの viewer pose を直接取得し、ワールド座標へ変換して保存しておけば、 MMD の視線追従処理はそれを見にいくだけで済みます。

結果として、Quest 2 の VR 中でも、会話時に MMD が正しくこちらを向くようになりました。

今回の一番大きな学びはここでした。


VR 中の移動処理も XR 基準に整理

移動処理についても、通常カメラではなく XR 側の向きを基準に整理しました。

const xrCam = renderer.xr.getCamera(camera);
xrCam.getWorldDirection(forward);
forward.y = 0;
forward.normalize();

これで、プレイヤーの向いている方向に対して自然な前後移動がしやすくなります。

ただし今回の本丸は移動そのものより、会話中の視線追従が本当にプレイヤーを見ているか でした。 ここが解決したことで、やっと VR 空間内でのキャラクターの存在感が自然になってきました。

input type=“color” が使えない

Wallpaper UI 側でも、Quest Browser 特有の問題がありました。

PC の Chrome では <input type="color"> のネイティブカラーピッカーが出るので便利です。 ところが Quest Browser ではこれが期待通りに動かず、実質的に色調整 UI として使いにくい状態でした。

そのため、Tint Color の入力はネイティブの color picker をやめて、以下のような自前 UI に置き換えました。

  • RGB スライダー
  • HEX カラーコード表示 / 入力
  • Brightness スライダー

これで Quest 2 でも確実に色変更できるようになりました。

もちろん、PC のネイティブカラーピッカーに比べると直感性は落ちます。 ただ、実機で使えない便利 UI より、多少地味でも確実に動く自前 UI の方が強い という判断です。

さらに Brightness スライダーを追加したことで、「色味」と「明るさ」を分けて扱えるようになり、最低限の調整はかなりやりやすくなりました。

今回のまとめ

今回の Quest 2 単体ブラウザ対応で、特に重要だったポイントは次の通りです。

  • Quest 2 で WebXR を試すには HTTPS が重要
  • next dev --experimental-https -H 0.0.0.0 で開発しやすくなった
  • crypto.randomUUID() は実機環境で問題になることがある
  • 実機デバッグでは画面内ログが非常に有効
  • VR 中の視線追従は、単純に camera.position を見るだけではズレる
  • renderer.xr.getCamera() だけでは不十分なケースがある
  • animate(time, frame)frame.getViewerPose() を使うと、そのフレームの最新 HMD pose を正しく取れる
  • Quest Browser ではネイティブ UI に依存しすぎない方が安全

WebXR は、VR ボタンを出して入るだけならそれほど難しく見えません。 ただ、実機で自然に見える・動く・追従する ところまで詰めると、細かい罠がかなり多いです。

今回そこを一通り越えたことで、Quest 2 単体ブラウザで動く Three.js + MMD 時計アプリの基盤としてはかなり強くなったと思います。