[Astro #42] R3FでWebXR環境構築:VRMの激重カクつき問題をHitboxで解決
はじめに
前回の「#41 Astro + React Three FiberでGLBステージの切り替え」に引き続き、今回はついに WebXR の世界へダイブします。
[Astro #41] React Three FiberでGLBモデルのステージ配置と動的切り替えを実装 // PROTOCOL.LAIN
AstroとReact Three Fiberを組み合わせたサイト開発。GLBモデルを用いたステージの配置と自転アニメーション、JSONによる動的切り替え、そしてスマホ対応のメニューUI実装についてまとめました。
lain-lab.comAstroとReact Three Fiber (R3F) の環境に @react-three/xr を導入し、VRヘッドセットから直接アクセスできる空間を構築しました。
しかし、そこに「VRMモデル(アバター)」を配置した途端、VRコントローラーのレーザーポインターを向けた瞬間に画面がカクつく(コマ送りになる)という致命的な問題に直面。今回はXRの初期化から、このパフォーマンス問題の劇的な解決方法までをまとめます。
動画:
1. @react-three/xr (v6) による初期化とカメラ調整
WebXR環境の構築には @react-three/xr を使用しますが、最新の v6 から仕様が大きく変わり、createXRStore というストアベースの管理になりました。
import { Canvas } from '@react-three/fiber';
import { XR, createXRStore, XROrigin } from '@react-three/xr';
// 1. XRストアの作成
const store = createXRStore({
emulated: true, // ブラウザでのエミュレートを有効化
hand: true,
});
export const WiredScene = () => {
return (
<>
{/* 2. VR空間に入るためのトリガーボタン */}
<button onClick={() => store.enterVR()}>
[ ENTER_WIRED_VR ]
</button>
<Canvas>
{/* 3. XRコンポーネントでシーンをラップする */}
<XR store={store}>
{/* ★重要: VR空間でのプレイヤーの立ち位置(基準点)を下げる */}
<XROrigin position={[0, -1.0, 2.5]} />
<ambientLight intensity={1.5} />
{/* ここにステージやアバターを配置 */}
</XR>
</Canvas>
</>
);
};
ポイント:
- 以前のバージョンにあった
<VRButton>は非推奨となり、自分で用意したボタンからstore.enterVR()を発火させるスタイルになりました。 - VRに入ると通常のカメラ設定が無視されるため、プレイヤーの初期位置(高さなど)の調整には
<XROrigin>コンポーネントを使用します。
2. 致命的な問題:VRMにレーザーを当てると激重になる
XR空間にステージとVRMモデルを配置し、いざ実機(Quest等)で入ってみると、コントローラーから出るレーザーポインターがアバターに触れた瞬間にフレームレートが急降下し、画面がガクガクになってしまいました。
原因は「Raycaster(当たり判定)の計算負荷」
コントローラーのレーザーは、毎フレーム「空間内のどのメッシュと衝突しているか」を計算しています。 VRMモデルは数万ポリゴンという非常に細かいメッシュで構成されているため、そこにレーザーが当たると、コンピューターが「どの細かい三角形の面に当たったか」を全パーツに対して必死に計算しようとして処理落ちを引き起こしていたのです。
3. 解決策:物理演算とグラフィックの分離(Hitboxの導入)
この問題を解決するための「最終奥義」は、VRMモデル自身の当たり判定を完全に消滅させ、代わりに超軽量な「透明なカプセル(Hitbox)」に当たり判定を任せるという、ゲーム開発の基本テクニックです。
ステップ1: VRMモデルの当たり判定を消滅させる
モデルを読み込んだ直後に traverse を使い、全メッシュの raycast を無効化します。
useEffect(() => {
if (gltf) {
// モデル内のすべてのメッシュを巡回し、レーザーの計算対象から完全に外す
gltf.scene.traverse((child: any) => {
if (child.isMesh) {
child.raycast = () => null; // 当たり判定を無に帰す
}
});
// ... その他の初期化 ...
}
}, [gltf]);
ステップ2: 透明な「身代わり(Hitbox)」を用意する
アバターの見た目とは別に、当たり判定専用の透明な <mesh> を用意し、そこにクリックやドラッグのイベントを集約します。
// useFrame内で、Hitboxが常にアバターの座標に追従するように設定
useFrame(() => {
if (hitboxRef.current && vrmRef.current) {
hitboxRef.current.position.set(
vrm.scene.position.x,
vrm.scene.position.y + currentHeadHeight / 2, // 体の中心に合わせる
vrm.scene.position.z
);
}
});
return (
<group>
{/* 1. 表示用アバター(当たり判定なし) */}
<primitive object={gltf.scene} />
{/* 2. 当たり判定用 Hitbox(10数ポリゴンの超軽量カプセル) */}
<mesh
ref={hitboxRef}
visible={false} // 本番では透明にする
onPointerDown={handleDragStart}
onPointerUp={handleDragEnd}
onClick={() => console.log("SYSTEM_LOG: AVATAR_CONTACT_DETECTED")}
>
{/* アバターをすっぽり覆うカプセル形状 */}
<capsuleGeometry args={[0.3, 1.2, 4, 8]} />
<meshBasicMaterial color="#00ffcc" wireframe transparent opacity={0.5} />
</mesh>
</group>
);
結果
レーザーが当たる対象が「数万ポリゴンのVRM」から「12ポリゴンのカプセル」に変わったことで、カクつきは完全に消失! しかも、透明なカプセルがイベントを受け取ってくれるため、VR空間でもマウス操作でも「アバターに触れる(クリックする・ドラッグする)」というインタラクションを維持することができました。
まとめと次回予告
WebXRにおけるパフォーマンスチューニングの重要性を身をもって体感しました。複雑なモデルを扱う際は、「見た目」と「当たり判定」を分けることが鉄則です。
次回は、この「触れる」ようになったアバターの横に、VR空間内で操作できる「空間UI(ホログラムメニュー)」を実装していく予定です。
(メモ:AstroでR3Fのコンポーネントを呼び出す際は、client:only="react" を忘れないこと!)
編集のアドバイス
- 記事内に、Hitboxを
visible={true}にした状態(赤いワイヤーフレームのカプセルが表示されている状態)のスクリーンショットを載せると、読者に「見た目と当たり判定の分離」が視覚的に伝わって非常に分かりやすくなります。
この記事を公開したら、次はいよいよ VR空間用ホログラムメニュー の実装ですね!準備ができたら教えてください。