[Next.js] #42 - Three.js HTMLMesh で既存の HTML UI を WebXR 空間へ移植する

はじめに

かなりまえに、MMD時計を、WebXRに対応・実装してたのですが、別の実装ばかりしてVR方面は放置してましたが、HTML・CSSで実装したDOMのボタンUIをVR空間内で表示&ボタン操作できないかと調べていて、HTMLMeshを使うと出来そうなことが分かり、久しぶりにVR実装をしてみたメモです。

これまで 2D ブラウザ向けに構築してきた複雑な HTML/CSS 資産を無駄にすることなく VR(WebXR)空間へ移植しています。

ただ、画面のスクロールが出来ないなど、何かと制限がある為、完全に実装するまではできなかったので過剰な期待はしない方がいいです。
細かいところは自力で実装する必要があります。

前回の記事:

スクリーンショット(VR):

動画(YouTube):

動画(WebXR/VR):

1. 巨大な UI ロジックの解体と再構築

プロジェクトの成長に伴い、当初は一つのファイルに収まっていた ui.js が約 900 行にまで膨れ上がり、どこに何の処理があるのか把握するのが困難な「スパゲッティコード」化していました。これを機能ごとに独立したモジュールへ切り出すことで、保守性と拡張性を大幅に向上させました。

責任の分離:役割ごとのモジュール化

各パネル独自のロジック(テンプレートの流し込み、イベントリスナー、ステート管理)を専門のファイルへ分離しました。

  • model-panel.js: PMX モデルの選択、読み込み済みの actors(MMDモデル)との同期、および影やブルームといった表示設定の制御を担当します。
  • wallpaper-panel.js: 静止画や MP4 動画アセットの管理、IndexedDB からの読み込み、および動画サムネイルの動的生成ロジックを集約しました。
  • debug-panel.js: キャンバスの PNG キャプチャや、MMD の内部情報の JSON 書き出しなど、開発者向けのツール群を独立させました。
  • settings-panel.js / rig-panel.js: 時計のスケール調整や、モデルのボーン・モーフ操作といった、特定の目的に特化した操作系を分離しています。

司令塔としての ui.js

リファクタリング後の ui.js は、個別の詳細なロジックを持たず、UI 全体の「交通整理」を行う司令塔の役割に専念しています。

  • 初期化の統制: createUI() 関数が各パネルの init…Panel() を呼び出し、依存関係(DOM 操作用のヘルパーやエスケープ関数など)を注入します。
  • 動的なパネル切り替え (swapBody): ツールバーのボタンが押された際、.panel__body の中身を各パネルが生成した DOM 要素で即座に入れ替える仕組みを導入しました。これにより、HTML 上のパネル構造を一つに保ったまま、中身だけを軽量に切り替えることが可能です。
  • グローバルイベントの仲介: mmd:info などのカスタムイベントを受信し、現在開いているパネルに対して「表示を更新せよ」と通知を出すハブとして機能します。

メリット:開発スピードの加速

この再構築により、新しい機能(例:AI会話パネル)を追加したい時は、ui.js を汚すことなく新しいモジュールを作成してインポートするだけで済むようになりました。また、VR 実装のような大きな変更を加える際も、影響範囲を特定しやすくなったことが今回の WebXR 対応への大きな助けとなりました。

2. HTMLMesh:最短で VR UI を実現する魔法

VR 空間で UI を一から構築する場合、通常は Three.js の Mesh を組み合わせて板ポリゴンを作り、そこにテキストやボタンのテクスチャを貼り付ける膨大な作業が必要です。しかし、今回は HTMLMesh を採用することで、ブラウザ向けに作り込んだ HTML/CSS 資産をそのまま 3D 空間へ「召喚」することに成功しました。

既存の Web デザインを 3D 空間へ「投影」する

HTMLMesh は、指定した DOM 要素(今回はリファクタリングした .panel や .toolbar)をキャンバスとしてキャプチャし、3D オブジェクトのテクスチャとしてリアルタイムに描画する技術です。

  • デザインの完全再利用: すりガラス風の背景(frosted glass)やボタンのホバーエフェクトなど、style.css で定義した洗練されたデザインがそのまま VR 空間に浮き上がります。
  • 開発コストの劇的削減: VR 専用の UI ライブラリを一から学習・実装する必要がなく、使い慣れた Web 技術だけで空間 UI を構築できました。

導入時のハマりポイント:ディレクトリの移動

Three.js は進化が速く、バージョン r160 以降ではいくつかのユーティリティの配置場所が変更されています。当初、以前のドキュメントを参考に webxr フォルダからインポートしようとした際、404 Not Found エラーに直面しました。

現在は interactive ディレクトリに集約されているため、以下のようにパスを修正する必要があります。

// 正しいインポートパス(Three.js r160以降)
import { HTMLMesh } from "three/addons/interactive/HTMLMesh.js";
import { InteractiveGroup } from "three/addons/interactive/InteractiveGroup.js";

インタラクションの橋渡し:InteractiveGroup

単に HTMLMesh をシーンに置くだけでは、VR コントローラーでボタンを押すことはできません。ここで重要な役割を果たすのが InteractiveGroup です。

このグループは、VR コントローラーから放たれる「光線(レイ)」がどのボタンに当たったかを計算し、それを DOM の click や mousedown といったイベントに変換して伝えてくれます。これにより、VR 空間に浮いた HTML パネルを、まるでタブレットを操作するようにコントローラーで操作することが可能になりました。

3. 空間 UI の最適化:座標系と移動の同期

VR 空間における UI 実装で最も陥りやすい罠が「座標系」の扱いです。当初、UI グループをシーン全体(config.scene)に直接配置してしまったため、プレイヤーが移動すると UI が元の場所に置き去りにされたり、ワープ後の視点からは UI が見上げるほど高い位置に表示されたりといった問題が発生しました。

解決策:プレイヤーを「親」にする

この問題を解決するために、UI を管理する InteractiveGroup の親を scene から config.player(プレイヤーの移動用グループ) に変更しました。

// ./src/app.js
function setupVRUI() {
  const group = new InteractiveGroup(config.renderer, config.camera);

  // scene ではなく、移動用グループである player に追加する
  config.player.add(group);

  // ...
}

config.player はカメラを含み、VR 内での移動(スティック操作)に合わせてその座標が更新されるグループです。この中に UI を含めることで、プレイヤーが空間内をどれだけ歩き回っても、UI は常に自分の周囲に一定の距離を保って付いてくるようになります。

相対座標による精密なレイアウト

UI がプレイヤーの子要素になったことで、座標の指定が「世界全体の中心からの距離」ではなく「プレイヤーから見た相対距離」へと変わりました。これにより、VR 空間での最適な配置を直感的な数値で固定できるようになりました。

  • パネルの配置: uiMesh.position.set(1.2, 1.2, -1.0) とすることで、右斜め前方の、手が届きやすく視界を遮らない絶妙な位置にパネルを浮かせています。
  • ツールバーの配置: toolbarMesh.position.set(1.2, 1.9, -1.0) と設定し、パネルの少し上に並べることで、一連の操作エリアとして整理しました。

ポインター(レーザー)のズレも解消

また、この親子関係の整理は操作性にも劇的な改善をもたらしました。以前はプレイヤーが移動すると、手元のコントローラーと UI 上のポインター(レーザー)の指し示す位置が物理的にズレてしまい、ボタンが押せない状態になっていました。

コントローラー自体も InteractiveGroup(プレイヤーの子)に追加したことで、コントローラーの動き、放たれる光線、そして UI パネルの全てが同じ座標系で同期されるようになりました。結果として、現実のレーザーポインターを扱うような正確でストレスのない操作感を実現できました。

4. 没入感を守る「表示トグル」機能

VR の魅力は何と言ってもその没入感にあります。しかし、高機能な UI パネルが常に視界を遮っていては、せっかくの 3D 空間や MMD キャラクターの存在感が台無しになってしまいます。そこで、必要なときだけ UI を呼び出せる「表示トグル機能」を実装しました。

Gamepad API による直感的な操作

WebXR セッションから取得できる Gamepad API を活用し、Quest 2 コントローラーの物理ボタン入力をリアルタイムで監視しています。

  • ボタンの選定: 今回は gp.buttons[4] をトグルボタンとして割り当てました。これは、Quest 2 における左コントローラーの X ボタン、または右コントローラーの A ボタン に相当します。
  • 一括制御のメリット: InteractiveGroup として構築した config.vrUIGroup 全体の visible プロパティを操作することで、パネル、ツールバー、さらにはコントローラーから放たれるポインター(赤い線)までを一瞬で消去・再表示させています。

チャタリングを防ぐ「二重動作防止」ロジック

プログラミング上の大きな注意点は、VR の描画サイクル(毎秒 72 〜 90 回以上)と人間のボタン入力時間の差です。単に「ボタンが押されている間は visible を反転させる」という処理を書くと、ボタンを一瞬押しただけでも ON/OFF が高速で繰り返され、意図した通りの表示状態になりません。

これを防ぐため、以下のフラグ管理によるデバウンス(二重動作防止)を導入しました。

// app.js 内のロジック
if (toggleBtn && toggleBtn.pressed) {
  if (!isButtonPressed) { // 押された瞬間だけ実行
    vrUIPanelVisible = !vrUIPanelVisible;
    if (config.vrUIGroup) {
      config.vrUIGroup.visible = vrUIPanelVisible;
    }
    isButtonPressed = true; // 押しっぱなしを検知するためのフラグを立てる
  }
} else {
  isButtonPressed = false; // ボタンを離したときだけフラグをリセット
}

この「1 回のクリックで確実に 1 回だけ切り替わる」という当たり前の動作を徹底したことで、キャラクターをじっくり眺めたい時と、設定を変更したい時をストレスなく行き来できるようになりました。

5. モバイル VR 特有の課題:ファイル読み込み

PC ブラウザでの開発では当たり前のように使っている「ファイルのドラッグ&ドロップ」ですが、Quest 2 などのモバイル VR 環境のブラウザではこのジェスチャーが利用できないという大きな制約があります。VR 空間にいながら新しいモデルや壁紙を追加するためには、VR 特有のファイルアクセス手法を実装する必要がありました。

オーソドックスかつ確実な input type=“file” の活用

Quest 2 のブラウザにおいて、ローカルファイルにアクセスする最も確実な方法は、標準的なファイル選択タグを使用することです。今回は、デザイン性を損なわないよう隠し要素として <input type="file"> を配置し、UI 上の「File」ボタンをクリックした際に、JavaScript 経由でこの入力を発火させる仕組みを構築しました。

ロジックの共通化:processFile 関数の抽出

デスクトップでの「ドロップ」と VR での「ファイル選択」、どちらの経路からファイルが渡されても同じように処理できるよう、drag-drop-handler.js 内のロジックをリファクタリングしました。

// drag-drop-handler.js 内で共通化された処理
export async function processFile(file) {
  const name = file.name.toLowerCase();

  if (/\.zip$/.test(name)) {
    await importModelZip(file); // モデルのインポート
  } else if (/\.(mp4)$/i.test(name)) {
    await saveWallpaperVideoAsset(file); // 動画壁紙の保存
  }
  // ... モーション(VMD)等の判定もここへ集約
}

VR 内でのシームレスなアセット管理

この実装により、VR モード中に「File」ボタンをポインターで叩くと、Quest 2 標準のファイルマネージャーがオーバーレイで表示されるようになります。

  • モデルの着せ替え: ヘッドセット内に保存した ZIP ファイルを選択するだけで、VR 空間のキャラクターを即座に入れ替え可能になりました。
  • 空間のカスタマイズ: MP4 ファイルを選択して「動画壁紙」として適用することで、一瞬にして周囲の景色(背景壁)を自分好みの映像へと変えられます。

VR 空間から一歩も出ることなく、自分の持っているアセットを自由自在に展開できるようになったことで、本プロジェクトは単なる「時計アプリ」を超えた、実用的な「VR MMD プレイヤー」へと進化を遂げました。

本日の成果

今回の開発により、2Dブラウザ向けに作り込んだ「モデル選択」「壁紙変更」「設定変更」の全機能が、WebXRによるVR空間内でもシームレスに動作するようになりました。

  • 直感的なレーザー操作: InteractiveGroup を活用し、VRコントローラーから放たれる赤いレーザーポインター(Lineオブジェクト)で、ボタンのクリックやパネルの切り替えが直感的に行えます。
  • デザインの継承: HTMLMesh の採用により、style.css で定義した frosted glass(すりガラス)効果などの高度なデザイン資産を、一切書き換えることなく3D空間へ持ち込むことができました。
  • プレイヤー追従型UI: UIグループを config.player の子要素に配置したことで、VR空間内を移動してもUIが常に最適な位置(右斜め前)に寄り添う、快適な操作環境を実現しています。

次回の課題

VR空間での操作性は劇的に向上しましたが、実機検証を通じていくつかの技術的課題も見えてきました。

  • VR内スクロール操作の改善: 現状の HTMLMesh では、マウスホイールやスワイプを伴うスクロール操作が困難です。今後は、パネル内に専用の「▲」「▼」スクロールボタンを配置し、JavaScriptで scrollTop を操作する仕組みの導入を検討しています。
  • 描画負荷の最適化: HTMLMesh はDOMの変化を検知してテクスチャを再生成するため、時計のような頻繁に更新される要素が含まれるとGPU負荷が高まります。描画頻度の調整や、静的な要素と動的な要素の切り分けによる最適化が必要です。