[Astro #93] Mixed Reality // VRMアバターをQuest3の現実空間で歩かせる

[Astro #93] Mixed Reality // VRMアバターをQuest3の現実空間で歩かせる

はじめに

PROTOCOL.LAINのアバター(VRM)を、Quest3のパススルーMR空間で歩かせる機能を実装しました。

WebXRの plane-detection featureを使い、Quest3が認識した現実の床と壁のデータを取得し、VRMアバターが実際の床面に着地して壁に反射しながら部屋の中を歩き回ります。

技術スタックは Astro + React 19 + @react-three/fiber 9.5 + @react-three/xr 6.6.29 + three 0.183.2 + @pixiv/three-vrm です。

1. XR Store の分離

既存のVRモード(immersive-vr)と新しいMRモード(immersive-ar)では、WebXRセッションの種類が異なります。@react-three/xrcreateXRStore で2つのストアを分離しました。

// WiredScene.tsx
const vrStore = createXRStore({
  emulate: true,
  hand: { touchPointer: false },
});

const arStore = createXRStore({
  emulate: true,
  hand: { touchPointer: false },
  mode: 'immersive-ar',
  features: ['hit-test', 'plane-detection'],
});

<XR> コンポーネントは key で切り替えます。key が変わるとRemountされるため、VR↔MRのセッション切替が確実に行われます。

<XR
  key={appMode === 'MR' ? 'ar' : 'vr'}
  store={appMode === 'MR' ? arStore : vrStore}
>

2. XROriginの座標問題

MRモードでは <XROrigin> の position を [0, 0, 0] にする必要があります。

<XROrigin position={appMode === 'MR' ? [0, 0, 0] : [0, -1.4, 1.5]} />

VRモードではカメラの高さ調整のために Y=-1.4 を設定していますが、MRでこれを使うと仮想空間全体が現実の床から1.4m沈み込みます。Quest3のパススルーMRではWebXRが現実の床面を Y=0 として扱うため、XROriginもゼロにしなければ仮想オブジェクトと現実空間の位置が一致しません。

3. Quest3 ルームスキャンの罠

WebXRの plane-detection featureを有効にしてセッションを開始しても、frame.detectedPlanes が空のまま返ってくるケースがあります。

Quest3には2種類の空間認識があり、通常のガーディアン(プレイエリア境界)設定だけでは plane-detection にデータが渡されません。別途「ルームスキャン」を実行する必要があります。

Quest3の設定画面から「空間と境界線」→「部屋をスキャン」でLiDARによる部屋全体のスキャンを行うと、壁・床・天井・机などの平面データが detectedPlanes に反映されます。

スキャン後は19個の平面が検出されました。各平面は orientation プロパティを持ち、"horizontal"(床・天井・机)と "vertical"(壁)の2種類が返ります。

4. 平面データの取得方法

three/examples/jsm/webxr/XRPlanes.js はシーンにメッシュを追加して平面を視覚化しますが、そのメッシュの userData には orientation 情報が格納されておらず、床と壁を区別できませんでした。

解決策として、useFrame の第3引数から XRFrame を直接取得し、detectedPlanes API を使います。

useFrame((state, delta, xrFrame: any) => {
  const frame    = xrFrame ?? (state.gl.xr as any).getFrame?.();
  const refSpace = (state.gl.xr as any).getReferenceSpace?.();

  if (frame?.detectedPlanes && refSpace) {
    (frame.detectedPlanes as Set<any>).forEach((plane: any) => {
      const pose = frame.getPose(plane.planeSpace, refSpace);
      // plane.orientation === 'horizontal' or 'vertical'
      // plane.polygon: {x, y, z}[] — 平面の輪郭頂点
    });
  }
});

plane オブジェクトは planeSpace(XRSpace)、polygon(輪郭頂点の配列)、orientation(文字列)を持ちます。frame.getPose(plane.planeSpace, referenceSpace) でワールド座標系でのPoseが取得できます。

5. 床着地の実装

ShapeGeometryの構築

plane.polygon の頂点から THREE.ShapeGeometry を作り、Raycaster用の不可視メッシュとして使います。

const shape = new THREE.Shape(
  polygon.map((p: any) => new THREE.Vector2(p.x, p.z))
);
const geo = new THREE.ShapeGeometry(shape);

polygon の各頂点は平面のローカル座標系で、水平面の場合 xz がその平面上の2D座標になります。ShapeGeometry はデフォルトでXY平面に頂点を生成するため、水平面として使うにはX軸で-90度回転する必要があります。

geo.applyMatrix4(new THREE.Matrix4().makeRotationX(-Math.PI / 2));

この回転は mesh.rotation ではなくジオメトリ自体に適用します。mesh.quaternion にはPoseから取得した回転を設定するため、メッシュの回転と干渉しないようにするためです。

Poseの適用

const t = pose.transform;
mesh.position.set(t.position.x, t.position.y, t.position.z);
mesh.quaternion.set(
  t.orientation.x, t.orientation.y,
  t.orientation.z, t.orientation.w
);
mesh.updateMatrixWorld(true);

mesh.matrixAutoUpdate = false + mesh.matrix.fromArray() 方式では Raycaster がヒットしませんでした。position / quaternion を直接設定して updateMatrixWorld(true) を呼ぶ方式が正しく動作します。

天井・机の除外

orientation === 'horizontal' は床だけでなく天井や机の表面も含みます。Poseの Y 座標がカメラ位置より高い平面を除外します。

if (pose.transform.position.y > camera.position.y - 0.5) return;

Raycasterによる着地

アバターの現在位置から真下にレイを飛ばし、床メッシュとの交差点をアバターのY座標に反映します。

raycaster.set(
  new THREE.Vector3(pos.x, pos.y + FLOOR_RAY_FROM, pos.z),
  new THREE.Vector3(0, -1, 0)
);
const hits = raycaster.intersectObjects(floorMeshes);
if (hits.length > 0) {
  const targetY = hits[0].point.y + AVATAR_HEIGHT * scale;
  pos.y = THREE.MathUtils.lerp(pos.y, targetY, 0.1);
}

FLOOR_RAY_FROM はレイの発射高さオフセット(1.5m)です。アバターの足元からではなく十分に上から飛ばすことで、床の下にいる場合でもヒットさせます。

detectedPlanes のPoseはフレームごとに微小な揺れがあるため、lerp で滑らかに追従させています。直接代入すると上下の振動が目立ちます。

6. 壁衝突の実装

壁の衝突判定には XRPlanesthree/examples/jsm/webxr/XRPlanes.js)のメッシュをそのまま使います。

detectedPlanes から自前で構築した壁メッシュではRaycasterが安定しなかったのに対し、XRPlanes がシーンに追加するメッシュは matrixWorld が正しく設定されているため、Raycasterが正常にヒットします。

const xrPlanes = xrPlanesRef.current;
if (xrPlanes?.children.length > 0) {
  raycaster.set(
    new THREE.Vector3(pos.x, pos.y + 0.5, pos.z),
    dir.clone().normalize()
  );
  const hits = raycaster.intersectObjects(xrPlanes.children, true);
  if (hits.length > 0 && hits[0].distance < WALL_DETECT && hits[0].face) {
    const normal = hits[0].face.normal.clone()
      .transformDirection(hits[0].object.matrixWorld);
    normal.y = 0;
    normal.normalize();
    avatarDir.current = dir.clone().reflect(normal).normalize();
  }
}

進行方向にレイを飛ばし、WALL_DETECT(0.5m)以内に壁メッシュがあれば、面法線で進行方向を反射させます。法線のY成分をゼロにすることで、壁に沿った上下移動(壁を駆け上がる挙動)を防ぎます。

床判定と壁判定で異なるメッシュソースを使う構成になった理由は、XRPlanes のメッシュに orientation 情報がなく床と壁を区別できなかったためです。床は detectedPlanes から orientation === 'horizontal' で抽出して自前構築し、壁は XRPlanes の全メッシュに対して水平レイを飛ばす(水平レイは壁にしか当たらない)という分担にしています。

7. MR空間の透過設定

パススルーMRでは Three.js のシーン背景を透明にする必要があります。

useEffect(() => {
  if (appMode === 'MR') {
    scene.background = null;
    gl.setClearColor(0x000000, 0);
  }
}, [appMode, scene, gl]);

scene.background = null でスカイボックスを除去し、gl.setClearColor のアルファを0にすることで、レンダリングされない部分がパススルー映像として表示されます。Canvas側でも alpha: true の設定が必要です。

環境光(IBL)は <Environment>background={false} オプションで、光源としてのみ使用し背景としては描画しない設定にしています。

まとめ

WebXR の plane-detection を使ったMR実装では、以下の点が重要でした。

Quest3では通常のガーディアン設定とは別に「ルームスキャン」が必要です。これを行わないと detectedPlanes は空のままです。

detectedPlanes から直接平面データを取得する方法と、XRPlanes のメッシュを使う方法にはそれぞれ長所があります。前者は orientation で床・壁を区別でき、後者はRaycasterとの相性が良いです。

ShapeGeometry は XY 平面に生成されるため、水平面として使う場合はジオメトリ自体にX軸-90度回転を適用する必要があります。mesh.rotation での回転は pose.quaternion と干渉します。

Poseの適用は matrix.fromArray() ではなく position / quaternion を直接設定して updateMatrixWorld(true) を呼ぶ方式が確実です。