Meta QuestとThree.jsで現実世界にVRMモデルを召喚してみる

# はじめに
この記事は、[N高グループ・N中等部 Advent Calendar 2024](https://qiita.com/advent-calendar/2024/nhighschool-njuniorhighschool) 14日目の記事です。

https://qiita.com/advent-calendar/2024/nhighschool-njuniorhighschool

はじめまして、marilと申します。去年に引き続き、今年もN高グループ・N中等部 Advent Calendarに参加させていただきます。

今回は、Meta QuestとThree.jsを使って、VRMモデルを現実へ呼び出す方法をご紹介していきます。

# プロジェクト作成
今回はRemixを例に挙げて紹介していきますが、フレームワークはお好みでどうぞ。

```bash
bun create remix vrm-xr
```

必要なパッケージをインストールします。
```
bun add three @types/three @pixiv/three-vrm
```

# シーン作成
では、実装に取り掛かっていきます。
まず、Three.jsのシーンを作成し、XR機能を有効にします。

```jsx
import * as THREE from "three";
import { ARButton, OrbitControls } from "three/examples/jsm/Addons.js";

const setupScene = async () => {
  //WebXRを有効化
  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setAnimationLoop(animate);
  renderer.xr.enabled = true;
  document.body.appendChild(renderer.domElement);

  document.body.appendChild(
    ARButton.createButton(renderer, {
      requiredFeatures: ["plane-detection"],
    })
  );

  //シーンの作成
  const scene = new THREE.Scene();

  //カメラを追加
  const camera = new THREE.PerspectiveCamera(
    35,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
  );
  camera.position.set(0.0, 1.4, 7);

  const orbitControls = new OrbitControls(camera, renderer.domElement);
  orbitControls.screenSpacePanning = true;
  orbitControls.target.set(0.0, 0.0, 0.0);
  orbitControls.update();

  //ライトを追加
  const light = new THREE.HemisphereLight(0xffffff, 0xbbbbff, 3);
  light.position.set(0.5, 1, 0.25);
  scene.add(light);

  function animate() {
    renderer.render(scene, camera);
  }
};

export default function Index() {
  useEffect(() => {
    setupScene();
  }, []);

  return <div></div>;
}
```
これで、XRが有効なThree.jsシーンが作成されます。

# VRMモデルの読み込み
あらかじめ、```public```配下にmodelsフォルダを作成し、そこに読み込みたいvrmモデルを配置しておきます。

VRMモデルを読み込む関数を実装します。
```jsx
import { VRMUtils, VRMLoaderPlugin } from "@pixiv/three-vrm";
import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";

const LoadVRM = (path: string): Promise<GLTF> => {
  const loader = new GLTFLoader();
  loader.crossOrigin = "anonymous";
  const helperRoot = new THREE.Group();

  loader.register((parser) => {
    return new VRMLoaderPlugin(parser, {
      helperRoot: helperRoot,
      autoUpdateHumanBones: true,
    });
  });

  return new Promise((resolve, reject) => {
    loader.load(
      path,
      (gltf: GLTF) => {
        VRMUtils.removeUnnecessaryJoints(gltf.scene);

        if (gltf.userData.gltfExtensions?.VRM) {
          //VRM-0.xのモデルかどうかチェックする
          reject(
            "モデルのバージョンに互換性がありません。VRM-1.xのモデルを使用してください。"
          );
        }

        gltf.userData.vrm.scene.traverse((obj) => {
          obj.frustumCulled = false;
        });

        resolve(gltf);
      },
      (progress: { loaded: number; total: number }) =>
        console.log(
          "Loading model...",
          100.0 * (progress.loaded / progress.total),
          "%"
        ),
      (error) => {
        reject(error);
      }
    );
  });
};
```

```setupScene```関数に以下を追記します。
```diff_jsx
const setupScene = async () => {
  //WebXRを有効化
  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setAnimationLoop(animate);
  renderer.xr.enabled = true;
  document.body.appendChild(renderer.domElement);

  document.body.appendChild(
    ARButton.createButton(renderer, {
      requiredFeatures: ["plane-detection"],
    })
  );

  //シーンの作成
  const scene = new THREE.Scene();

  //カメラを追加
  const camera = new THREE.PerspectiveCamera(
    35,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
  );
  camera.position.set(0.0, 1.4, 7);

  const orbitControls = new OrbitControls(camera, renderer.domElement);
  orbitControls.screenSpacePanning = true;
  orbitControls.target.set(0.0, 0.0, 0.0);
  orbitControls.update();

  //ライトを追加
  const light = new THREE.HemisphereLight(0xffffff, 0xbbbbff, 3);
  light.position.set(0.5, 1, 0.25);
  scene.add(light);

+ //VRMの読み込み
+ const gltf = await LoadVRM("モデルのパス");
+ const vrm = gltf.userData.vrm;
+ scene.add(vrm.scene);
+ vrm.scene.position.set(0, 0, 0);

+ const clock = new THREE.Clock();

  function animate() {
+   const deltaTime = clock.getDelta();

+   //vrmのanimate
+   if (vrm) vrm.update(deltaTime);

    renderer.render(scene, camera);
  }
};
```

# Questからアクセスしてみる
これで、VRMモデルを現実空間に呼び出す準備が整いました。
実際にMeta Quest3側からアクセスしてみましょう。

今回は、Cloudflare Tunnelを使って一時的にWebサイトを公開します。

Quick Tunnelsのセットアップ方法は、以下の記事が参考になります。

https://qiita.com/youtoy/items/3d312552f08a729bb5bb

```
cloudflared tunnel --url localhost:5173
```

表示されたURLに、Meta Questからアクセスします。

「Start AR」というボタンが表示されていれば、正しくシーンが作成できています。
ボタンをタップして、確認ポップアップの「許可する」をタップします。
VRMモデルが召喚されていれば、成功です！

(初期位置ではたまにモデルが空中に浮遊したり、地面に埋まっていたりすることがあるので、その場合は```vrm.scene.position.set```でy座標を調節してみてください。)

# デバッグ
WebXRアプリを開発していると、エラーなどを確認するため、Quest3で動作しているWebサイトのコンソールを確認したくなることがあるかと思います。

そんなときは、Chromiumブラウザのリモートデバッグ機能を使用することで、PCからQuest上のWebサイトのコンソールを確認することができます。

デバッグの手順は、以下の記事が参考になります。

https://note.com/npaka/n/nc269a3c28b0c

# おわりに
今回は、Meta QuestとThree.jsを使って、VRMモデルを現実へ呼び出す方法をご紹介しました。

このように、Three.jsを使えば簡単にWebXRコンテンツを作成することができるので、ぜひ挑戦してみてください。