[Astro #48] WebXRスカイボックス実装 - 巨大球体アプローチとUIのタブ拡張

[Astro #48] WebXRスカイボックス実装 - 巨大球体アプローチとUIのタブ拡張

はじめに

前回「#47 VRMのT-pose警告修正」に引き続き、React Three Fiber (R3F) + WebXR で構築している電脳空間のアップデートです。

今回は、空間に広がりを持たせるための「スカイボックス(全天球背景)」の導入と、それに伴い肥大化してしまったシステムメニューのタブ化(UI改修)を行いました。

特にWebXR環境ならではの「背景回転バグ」にハマったので、その解決策となる「巨大球体アプローチ」について詳しくまとめます。

スクショ:

[Astro #48] WebXRスカイボックス実装 - 巨大球体アプローチとUIのタブ拡張

NOTE:

動画(YouTube):

動画(VR):

1. WebXRとスカイボックスの罠:視点が床にめり込む!?

Three.jsやReact Three Fiber (R3F) を使って3D空間に手軽にリッチな背景を設定する場合、@react-three/dreiEnvironment コンポーネントを使ったり、パノラマ画像を読み込んで scene.background に渡すのが一般的なセオリーです。

今回は、生成したファンタジー調の村や夜の浮遊城といったパノラマ画像を用意し、空間にさらなる広がりを持たせるために「背景をステージと同期させてゆっくりと回転させる」という演出を加えようとしました。 コードとしては、useFrame の中で scene.backgroundRotation.y の値を毎フレーム加算していく、非常にシンプルでよくある実装です。

PCやスマホのブラウザ上では、狙い通りに滑らかに回転し、「完璧だ」と思っていました。 しかし、いざヘッドセットを被り「Enter VR」ボタンを押してWebXRモードに入った途端、カメラの座標がバグり、視点が地面(床下)にめり込んでしまうという謎の現象に遭遇したのです。

原因:WebXRの絶対的な座標管理との衝突

なぜ、通常のブラウザでは動くコードがVRになると破綻するのか。その原因は、WebXR環境特有の「カメラ座標(リファレンススペース)の厳密な管理」にありました。

  • 通常のブラウザ(非VR)の場合: カメラは単なる3D空間上の仮想オブジェクトです。プログラムから自由に位置や回転を書き換えたり、空間全体(sceneのプロパティなど)を操作しても、描画エンジンは素直にそれに従います。
  • WebXR(VRモード)の場合: VRモードに入ると、ヘッドセットのセンサーが認識している「現実の床の高さ」や「プレイヤーの頭の絶対位置」が、空間の基準としてカメラ座標を強力に支配し始めます。

このような状態で、空間の根本的なシステムである scene のプロパティ(今回は backgroundRotation)を毎フレーム直接書き換えてしまうと、WebXRシステム側が行っている「プレイヤーは今ここに立っている」という計算と激しく衝突(コンフリクト)を起こします。

結果として空間のリファレンスがおかしくなり、「視点が床に落ちる」「トラッキングが効かなくなる」といった致命的なバグに繋がってしまったというわけです。VR開発あるあるの、非常に厄介な落とし穴ですね。

これを回避するためには、Three.jsが用意している「システム背景」という便利な機能に頼るのではなく、もっと物理的で力技なアプローチをとる必要がありました。

2. 解決策:物理的な「巨大な球体」を作る

WebXRのカメラ制御(リファレンススペース)と干渉せずに空間全体を回すにはどうすればいいか?答えは非常にシンプルで、「Three.jsのシステム背景機能に頼るのをやめる」ことでした。

代わりに、内側にパノラマ画像を貼り付けた超巨大な球体(スカイドーム)をただの3Dメッシュとして配置し、そのオブジェクト単体を回転させるという物理的なアプローチ(力技)を採用しました。

これなら描画エンジン側からは「ただの大きな3Dモデルが回っているだけ」という扱いになるため、WebXRのトラッキングシステムには一切干渉せず、視点が地面にめり込むことは絶対にありません。

const WiredSkybox = ({ rotationSpeed, texturePath }) => {
  const { scene } = useThree();
  const texture = useTexture(texturePath);

  // 色空間を正しく設定(VRでの白飛び防止に必須!)
  texture.colorSpace = THREE.SRGBColorSpace;
  texture.mapping = THREE.EquirectangularReflectionMapping;

  const skyRef = useRef();

  useFrame((state, delta) => {
    // 球体メッシュそのものを回転させる(カメラ座標には干渉しない!)
    if (skyRef.current) {
      skyRef.current.rotation.y += delta * rotationSpeed;
    }
    // 環境光(アバターへの照り返し)の回転も忘れずに同期させる
    scene.environmentRotation.y += delta * rotationSpeed;
  });

  return (
    <>
      {/* 1. 光源用:背景としては表示せず、環境光(IBL)としてのみ利用 */}
      <Environment map={texture} background={false} intensity={1.0} />

      {/* 2. 背景用:超巨大な球体の内側にテクスチャを貼って視覚的なスカイボックスにする */}
      <mesh ref={skyRef} scale={[-1, 1, 1]}>
        <sphereGeometry args={[500, 60, 40]} /> {/* 半径500mの巨大球体 */}
        <meshBasicMaterial
          map={texture}
          side={THREE.BackSide}
          toneMapped={false}
          color={[0.7, 0.7, 0.7]} // ★ここが超重要!
        />
      </mesh>
    </>
  );
};

このアプローチの最強のメリット:「露出の分離」

実はこの手法、単にVRのめり込みバグを回避するだけにとどまらず、空間の表現力を劇的に引き上げる最強の副産物をもたらしてくれました。それが「背景の明るさ」と「アバター(被写体)を照らす光の明るさ」の完全な分離です。

通常のスカイボックス機能では、背景画像そのものが全体の光源(IBL)を兼ねています。そのため「背景が眩しすぎるから暗くしよう」と全体の光の強度(intensity)を落とすと、手前のアバターまで真っ暗になってしまいます。逆にアバターを明るく照らそうとすると、今度はVR内で背景が白飛び(白浮き)してしまい、非常に安っぽい画面になりがちです。

しかし、今回のように「光(Environment)」と「見た目(Mesh)」を完全に分業させる手法なら、それぞれの明るさを独立してコントロールできます。

meshBasicMaterialcolor={[0.7, 0.7, 0.7]} のように設定するだけで、アバターの明るさは完璧に維持したまま、背景のテクスチャにだけグレーを掛けて意図的に暗く沈めることができるのです。これは、現実のスタジオ撮影でカメラマンが「背景の照明(バック紙)だけを落として、被写体にだけスポットライトを当てる」ようなプロのライティング環境をコード上で再現したことと同義です。

結果として、VR特有の色あせを防ぎつつ、アバターの輪郭やUIのネオンカラーがパキッと浮かび上がる、没入感抜群の「深い電脳空間」を作り出すことに成功しました。

3. システムメニューの拡張(タブ化)

スカイボックスや環境設定の機能が追加されたことで、嬉しい悲鳴ではありますが、設定メニューが縦に長くなりすぎてしまいました。特にスマホの画面や、コントローラーのポインターで操作するVR空間内では、縦に長いリストは誤操作の原因になり、UXを大きく損ねてしまいます。

そこで操作性を抜本的に改善するため、メニュー全体を AVATAR(モデル選択)、STAGE(足場)、SKY(背景)、SYS(システム設定)の4つのタブに分割しました。

ReactにおけるタブUIの実装は、大掛かりなライブラリを導入しなくても、State(状態)を1つ追加するだけで非常にシンプルに実現できます。

// タブの状態管理(初期値は 'avatar')
const [activeTab, setActiveTab] = useState<'avatar' | 'stage' | 'sky' | 'system'>('avatar');

// ...UIボタン部分...
<div style={{ display: 'flex', gap: '4px' }}>
  <button onClick={() => setActiveTab('avatar')}>AVATAR</button>
  <button onClick={() => setActiveTab('stage')}>STAGE</button>
  <button onClick={() => setActiveTab('sky')}>SKY</button>
  <button onClick={() => setActiveTab('system')}>SYS</button>
</div>

{/* 条件付きレンダリングで中身をスパッと切り替え */}
{activeTab === 'sky' && (
  <div>
    {/* ここにスカイボックスのサムネイル一覧を描画 */}
  </div>
)}

JSONによる動的リスト化とUIの視覚化

これからの時代、AI(今回はGemini 3と画像生成で共同作業しました)を活用すれば、ファンタジーの村やサイバーパンクの街など、背景やステージのバリエーションは息をするように量産できてしまいます。

将来的にアセットが爆発的に増えていくことを見越して、コード内にベタ書きするのではなく、スカイボックスのデータを skybox.json として外部ファイル化しました。 さらに、ただ文字が並ぶだけのリストを廃止し、画像をグリッド状に並べた「サムネイル表示」へとUIを改修しました。これにより、「どの世界にダイブするか」を直感的に、そしてワクワクしながら選べるようになっています。

DOMとWebGL(VR)のUI同期

もちろん、このUI改修はPC/スマホ用のDOMメニューだけでなく、VR空間内に浮かぶ操作パネル(@react-three/uikit を使用)にも全く同じレイアウトで実装しています。

親コンポーネント(WiredScene)で「現在選ばれているスカイボックスのID」をStateとして一元管理し、DOMメニューとVRメニューの両方にバケツリレー(Propsのバイパス)で伝達することで、「PCで選んだ背景がVRにも即座に反映され、VR内で変更すればPC側も同期する」という完璧なシームレス体験を実現させました。

まとめ

今回は「スカイボックスの導入」というシンプルな目的から始まりましたが、WebXRならではの座標の罠を「巨大球体」という物理的アプローチで回避し、結果的に「アバターと背景のライティングを完全分離する」という非常に強力な武器を手に入れることができました。

さらに、タブ化とJSONによるデータ管理を整備したことで、UIの拡張性もバッチリ確保できました。基盤は整ったので、今後はAIを活用して様々な世界(アセット)をどんどんこの電脳空間に追加していこうと思います。