[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(設定メニュー)
MRAvatar は useWiredVRM フックで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. 歩行アニメーション
MRManager と MRAvatar の間は 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アニメーションにはルートモーション(アニメーション自体に座標移動が焼き込まれているもの)があります。座りアニメーションの再生中にアバターが前方にずれる問題が発生しました。
MRAvatar の useFrame 内で、座り中は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 から毎フレーム生成する ShapeGeometry と MeshBasicMaterial を、次フレームで置き換える前に 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. 段差検出と座りイベント
アバターが段差(床面の高さが急変する箇所)に到達した際の挙動を実装しました。
ステートマシン
アバターの状態は WALK → SIT_DOWN → SIT_IDLE → WALK のステートマシンで管理しています。
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で、座りイベントや衝突判定の有無を実行中に切り替えられます。