[Astro #94] Mixed Reality Day2 // 責務分離・歩行アニメーション・段差イベント・設定UI

[Astro #94] Mixed Reality Day2 // 責務分離・歩行アニメーション・段差イベント・設定UI

はじめに

前回の記事で、WebXRの plane-detection を使ってVRMアバターをQuest3の現実空間で歩かせる基本機能を実装しました。

今回はその続きとして、コンポーネントの責務分離、歩行アニメーション、メモリリーク対策、段差検出による座りイベント、VRコントローラーでの設定UIなど、MRモードの品質を大幅に改善しました。

1. MRAvatar — 責務の分離

前回の実装ではトップページ用の WiredAvatar コンポーネントをMRモードでも流用していました。しかし WiredAvatar は自律行動、魔法エフェクト、箒飛行、アクセサリ装着など、MRモードでは不要な機能を大量に含んでいます。

MR専用のアバターコンポーネント MRAvatar.tsx を新設し、責務を分離しました。

src/components/PROJECT_LAIN/MR/
├── MRManager.tsx    // 空間管理(床着地・壁衝突・段差検出)
├── MRAvatar.tsx     // アバター(VRM読み込み・アニメーション制御)
├── MRController.tsx // 入力(VRコントローラーのボタン処理)
└── MRMenu.tsx       // UI(設定メニュー)

MRAvataruseWiredVRM フックでVRMを読み込み、歩行・座り・アイドルのアニメーション切り替えだけを担当します。WiredAvatar は一切変更していません。

2. AVATAR_HEIGHT の根本解決

前回、アバターが床に埋まる問題を AVATAR_HEIGHT のハードコードで回避していましたが、根本原因は useWiredVRM フック内の以下の行でした。

vrm.scene.position.set(0.5, -1.2, 0);

トップページでの表示位置調整のために設定されたオフセットが、MRモードでも適用されていたため、アバターが常に1.2m下にずれていました。

MRAvatar ではVRM読み込み後にこのオフセットをリセットしています。

useEffect(() => {
  if (gltf?.userData?.vrm) {
    gltf.userData.vrm.scene.position.set(0, 0, 0);
  }
}, [gltf]);

この修正により AVATAR_HEIGHT = 0.0 でアバターが正確に床面に立つようになりました。責務分離の副産物として、ハードコードが完全に不要になりました。

3. 歩行アニメーション

MRManagerMRAvatar の間は window オブジェクトのプロパティで状態を共有しています。

// MRManager.tsx — 歩行開始時
(window as any).wiredMRState = 'WALK';

// MRAvatar.tsx — useFrame内で状態を読み取り
const mrState = (window as any).wiredMRState || 'WALK';
if (animStateRef.current !== mrState) {
  animStateRef.current = mrState;
  switch (mrState) {
    case 'WALK':
      playAnimationById('SYS_WALKNING', 'LOOP');
      break;
    case 'SIT_DOWN':
      playAnimationById('SYS_STAND_TO_SIT', 'ONCE_AND_HOLD');
      break;
    case 'SIT_IDLE':
      playAnimationById('IDLE_SITTING_IDLE', 'LOOP');
      break;
  }
}

アニメーションの切り替えは ONCE_AND_HOLD(1回再生して最終フレームで停止)と LOOP(ループ再生)を使い分けています。座りモーションは ONCE_AND_HOLD で再生し、座り姿勢になった後に座りアイドルの LOOP に遷移します。

4. ルートモーションの位置ズレ防止

VRMアニメーションにはルートモーション(アニメーション自体に座標移動が焼き込まれているもの)があります。座りアニメーションの再生中にアバターが前方にずれる問題が発生しました。

MRAvataruseFrame 内で、座り中はVRMのローカル座標とHipsボーンの水平座標を強制リセットしています。

if (mrState === 'SIT_DOWN' || mrState === 'SIT_IDLE') {
  if (gltf?.userData?.vrm) {
    const vrm = gltf.userData.vrm;
    vrm.scene.position.set(0, 0, 0);

    const hips = vrm.humanoid?.getNormalizedBoneNode('hips');
    if (hips) {
      hips.position.x = 0;
      hips.position.z = 0;
    }
  }
}

5. メモリリーク対策

初期実装ではMRモードに入って数分でフリーズし、ブラウザが真っ白になる問題が発生しました。原因は3つのメモリリークでした。

Raycasterの毎フレーム生成

// ❌ 毎フレーム new していた
const raycaster = new THREE.Raycaster();

// ✅ useRef で1回だけ生成
const raycasterRef = useRef(new THREE.Raycaster());

geometry / material の未解放

detectedPlanes から毎フレーム生成する ShapeGeometryMeshBasicMaterial を、次フレームで置き換える前に dispose() するようにしました。Three.jsのオブジェクトはJavaScriptのGCだけでは解放されず、明示的に dispose() を呼ばなければVRAMを消費し続けます。

floorMeshesRef.current.forEach(m => {
  m.geometry?.dispose();
  (m.material as THREE.Material)?.dispose();
});

setDebugText の毎フレーム呼び出し

React の setState を毎フレーム呼ぶと、毎フレーム再レンダリングが発生します。30フレームに1回に間引きました。

frameCountRef.current++;
if (frameCountRef.current % 30 === 0) {
  setDebugText(`fl:${floorN} ...`);
}

6. 段差検出と座りイベント

アバターが段差(床面の高さが急変する箇所)に到達した際の挙動を実装しました。

ステートマシン

アバターの状態は WALKSIT_DOWNSIT_IDLEWALK のステートマシンで管理しています。

WALK(歩行中)
  ↓ 段差検出
SIT_DOWN(座るモーション / 2秒)

SIT_IDLE(座りアイドル / 10秒)

WALK(方向転換して歩行再開)

段差判定

前フレームの床Y座標と現フレームの床Y座標の差が0.15mを超えた場合に段差と判定します。

if (mrStateRef.current === 'WALK'
    && lastY !== null
    && Math.abs(newFloorY - lastY) > 0.15
    && sitCooldownRef.current <= 0) {

段差を検出した場合は段差に登らず、元の床の高さを維持したまま方向転換して座ります。座りから歩行に復帰した後は一定時間(SIT_COOLDOWN)段差を無視して、連続的に座りモーションが発生するのを防いでいます。

座りイベントの ON/OFF

設定UIから座りイベントを無効化できます。無効時は段差で方向転換のみ行い、座りアニメーションは発生しません。

7. 視線追従

アバターがユーザー(カメラ)の方向を見るようにしました。useWiredVRM が設定する lookAt ターゲットを、毎フレームカメラのワールド座標に lerp で追従させています。

if (isLookAtEnabled && vrmRef.current) {
  const cameraPos = new THREE.Vector3();
  state.camera.getWorldPosition(cameraPos);
  lookAtTargetRef.current.position.lerp(cameraPos, 0.1);
}

lerp の係数を0.1にすることで、視線が瞬間的にカチカチ動くのではなく、滑らかにカメラを追いかけます。

8. XRPlanes の表示制御

デバッグ中はQuest3が検出した平面をカラフルなメッシュとして表示していましたが、MRモードとして使用する際には不要です。

XRPlanes をシーンに追加しつつ visible = false にすることで、レンダリングを抑制しながらRaycasterの衝突判定は維持できます。

const xrPlanes = new XRPlanes(gl);
scene.add(xrPlanes);        // matrixWorld更新のためシーンに追加
xrPlanes.visible = false;   // レンダリングだけ非表示

scene.add 自体をやめるとXRPlanesの子メッシュの matrixWorld が更新されなくなり、壁衝突のRaycasterが機能しなくなります。

9. MRController と MRMenu

VRコントローラーの右手Bボタンで設定UIを開閉できるようにしました。

MRController はWebXRの inputSources から右手コントローラーのBボタン(buttons[5])を監視し、トグル判定を行います。

MRMenu@react-three/uikit で構築した3D空間内のUIパネルです。デバッグモード、座りイベント、衝突判定の各フラグをON/OFFできます。

10. 衝突時のランダムアイドルとメッセージ

ユーザーとの衝突時にはランダムなアイドルアニメーションに切り替わり、Messages.json で定義されたセリフを表示します。

[
  { "id": "COL_01", "trigger": "COLLISION", "text": "あっ、ごめんなさい…" },
  { "id": "COL_02", "trigger": "COLLISION", "text": "お仕事頑張って" },
  { "id": "SIT_01", "trigger": "SIT_DOWN", "text": "ちょっと休憩…" }
]

衝突や座りなどのトリガーに応じてランダムにセリフが選択されます。

まとめ

MR実装2日目で、以下の改善を行いました。

MRAvatar への責務分離により、useWiredVRM の座標オフセット問題(AVATAR_HEIGHT のハードコード)が根本解決しました。トップページ用の WiredAvatar を汚さずにMR固有の機能を積み重ねられる構造になっています。

メモリリーク対策(dispose()、Raycasterのref化、setState の間引き)により、MRセッションの安定性が向上しました。

段差検出 → 座りイベントのステートマシンにより、アバターの挙動にバリエーションが生まれました。VRコントローラーからの設定UIで、座りイベントや衝突判定の有無を実行中に切り替えられます。