みかづきブログ・カスタム

基本的にはちょちょいのほいです。

Next.js(14.2.12)+ Three.js(r168)で、Meta Quest向けにWebVRコンテンツをつくる 🕶️

2022年はWebVR、2023年はWebARに関する知見をまとめたりしていましたが、久しぶりにWebVRコンテンツを作ってみました。

kimizuka.org

内容としては、

  • 目の前にCubeがひとつ浮いている
  • コントローラ(もしくは手)が表示されている
  • コントローラ(もしくは手)でCubeに触れるとインタラクションがある

というシンプルなものです。

以前作った環境を使って作ろうかな。とも思ったんですが、

  • Three.jsが古い(以前は、r136、r127、r111を使っていた)
  • Next.jsのPageRouterで作成している
  • 自作のカスタムフックがイケてない(いま見返すと直したい部分が多々あった)

の3点から、心機一転新たな環境で作り直してみました。

方針としては、

  • Three.jsはr168を使う(開発時最新)
  • Next.jsは14.2.12(開発時最新)を使い、AppRouterで開発する
  • TypeScriptを使う
  • 余計なカスタムフックを作らない(公開されているライブラリを使う)

という感じで実装しようを試みました。
本当はClassを使ったりした方がいい感じに書けそうなところもあったりした(オブジェクトの当たり判定の部分とか)んですが、敢えてコードを分けずに、だだだーっと書いています。

ここまで環境を変えるのであれば、 @react-three/fiber@react-three/xr を使ってみようかなとも思ったんですが、一旦、Next.js と Three.js のみで作成してみました。(のちに、Next.js + Three.js + @react-three/fiber + @react-three/xr バージョンも試作してみましたが、驚くほど簡単につくることができました)

DEMO

https://next-js-vr-button.vercel.app/

Meta Quest Proでしか確認していませんが、ブラウザでアクセスするとWebVRが体験できるはずです。


リポジトリ

github.com

ソースコード

package.json

{
  "name": "next-js-vr-button",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "dev:https": "next dev --experimental-https",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "@uidotdev/usehooks": "^2.4.1",
    "next": "14.2.12",
    "react": "^18",
    "react-dom": "^18",
    "the-new-css-reset": "^1.11.3",
    "three": "^0.168.0"
  },
  "devDependencies": {
    "@types/node": "^22",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "@types/three": "^0.168.0",
    "typescript": "^5"
  }
}

localhostの状態で、Meta Questでプレビューできた方が便利なので、Next.jsの開発サーバをhttpsで立ち上げられるようにしておきます

blog.kimizuka.org

なぜならば、httpsじゃないと、WebVRを実行できないからです。

src/app/layout.tsx

import 'the-new-css-reset';
import type { ReactNode } from 'react';

export default function RootLayout({
  children,
}: Readonly<{
  children: ReactNode;
}>) {
  return (
    <html lang="ja">
      <body>{children}</body>
    </html>
  );
}

reset.cssを読み込んでおきます。
特にこだわりはないのですが、最近は the-new-css-reset を使っています。

www.npmjs.com

src/app/page.tsx

import { GameScene } from '@/components/GameScene';

export default function IndexPage() {
  return <GameScene />;
}

コンポーネントを読み込むだけにしておきます。

src/components/GameScene/index.tsx

'use client';

import { useWindowSize } from '@uidotdev/usehooks';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
  BoxGeometry,
  Mesh,
  MeshNormalMaterial,
  PerspectiveCamera,
  Raycaster,
  Scene,
  Sphere,
  SphereGeometry,
  Vector2,
  Vector3,
  WebGLRenderer,
} from 'three';
import { VRButton } from 'three/addons/webxr/VRButton.js';
import { XRControllerModelFactory } from 'three/examples/jsm/webxr/XRControllerModelFactory.js';

const controllerModelFactory = new XRControllerModelFactory();

export function GameScene() {
  const { width, height } = useWindowSize();
  const [renderer, setRenderer] = useState<WebGLRenderer | null>(null);
  const [camera, setCamera] = useState<PerspectiveCamera | null>(null);
  const [scene, setScene] = useState<Scene | null>(null);
  const [isHit, setIsHit] = useState(false);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const isHitRef = useRef(false);

  // renderer、camera、sceneを作成し、すべてが作成できたら初期化処理を行う
  useEffect(() => {
    if (!canvasRef.current) {
      return;
    }

    if (!renderer) {
      const newRenderer = new WebGLRenderer({
        canvas: canvasRef.current,
        antialias: true,
        alpha: true,
      });

      newRenderer.setPixelRatio(window.devicePixelRatio);

      setRenderer(newRenderer);
    }

    if (!camera) {
      const newCamera = new PerspectiveCamera();

      setCamera(newCamera);
    }

    if (!scene) {
      const newScene = new Scene();

      setScene(newScene);
    }

    if (renderer && camera && scene) {
      init();
    }
  }, [renderer, camera, scene]);

  // リサイズされたらCanvasのサイズを変更する
  useEffect(() => {
    if (!width || !height) {
      return;
    }

    if (!renderer || !camera) {
      return;
    }

    camera.aspect = width / height;
    camera.updateProjectionMatrix();
    renderer.setSize(width, height);
  }, [width, height, renderer, camera]);

  const init = useCallback(() => {
    // renderer、camera、sceneが揃うまで進ませない
    if (!renderer || !camera || !scene) {
      return;
    }

    renderer.xr.enabled = true; // XRを有効にする
    renderer.setClearColor(0xffffff, 1);

    const cube = new Mesh(
      new BoxGeometry(0.4, 0.4, 0.4),
      new MeshNormalMaterial(),
    );

    cube.position.set(0, 1.5, -1); // 0, 0, 0に置かない(WebVRのスタート地点が0, 0, 0なので)

    cube.geometry.computeBoundingSphere(); // 当たり判定用の球体をつくる

    // 当たり判定用の球体と同じ大きさの球体をcubeと同じ座標に置く
    const cubeHit = new Sphere(
      cube.position,
      cube.geometry.boundingSphere?.radius || 0,
    );

    // デバッグ用に当たり判定と同じ大きさのsphereを作成
    const sphere = new Mesh(
      new SphereGeometry(cube.geometry.boundingSphere?.radius, 8, 8),
      new MeshNormalMaterial(),
    );
    sphere.material.transparent = true;
    sphere.material.opacity = 0.4;

    scene.add(sphere);

    sphere.position.set(cube.position.x, cube.position.y, cube.position.z);

    scene.add(cube);

    // コントローラの当たり判定用の球体
    const sphereA = new Mesh(
      new SphereGeometry(0.1, 8, 8),
      new MeshNormalMaterial(),
    );
    // 二刀流に備えたコントローラの当たり判定用の球体
    const sphereB = new Mesh(
      new SphereGeometry(0.1, 8, 8),
      new MeshNormalMaterial(),
    );
    const controllerA = renderer.xr.getController(0);
    const controllerB = renderer.xr.getController(1);
    const controllerModelA =
      controllerModelFactory.createControllerModel(controllerA);
    const controllerModelB =
      controllerModelFactory.createControllerModel(controllerB);

    sphereA.geometry.computeBoundingSphere();
    sphereB.geometry.computeBoundingSphere();

    const hitA = new Sphere(
      controllerA.position,
      sphereA.geometry.boundingSphere?.radius,
    );
    const hitB = new Sphere(
      controllerB.position,
      sphereB.geometry.boundingSphere?.radius,
    );

    sphereA.material.transparent = true;
    sphereA.material.opacity = 0.4;

    sphereB.material.transparent = true;
    sphereB.material.opacity = 0.4;

    controllerA.add(controllerModelA);
    controllerA.add(sphereA);

    controllerB.add(controllerModelB);
    controllerB.add(sphereB);

    scene.add(controllerA);
    scene.add(controllerB);

    camera.position.set(0, 1.7, 0); // 自分の目の位置ぐらいにカメラを置く
    camera.lookAt(new Vector3(cube.position.x, cube.position.y, cube.position.z)); // カメラをcubeの方に向ける

    scene.add(camera);

    // デバッグ用にWebVRに入らなくてもCubeの当たり判定をクリックすればCubeを触ったことにする
    window.addEventListener('click', async (evt: MouseEvent) => {
      const raycaster = new Raycaster();
      const vector = new Vector2(
        (evt.clientX / window.innerWidth) * 2 - 1,
        (evt.clientY / window.innerHeight) * -2 + 1,
      );

      raycaster.setFromCamera(vector, camera);

      const intersects = raycaster.intersectObjects(scene.children);

      if (intersects[0]?.object === sphere) {
        hit();
      }
    });

    // 「ENTER VR」ボタンを設置する
    document.body.appendChild(VRButton.createButton(renderer));

    renderer.setAnimationLoop(() => {
      renderer.render(scene, camera);

      // 毎フレーム、コントローラとCubeが衝突していないかを判定する
      hitA.center.set(
        controllerA.position.x,
        controllerA.position.y,
        controllerA.position.z,
      );
      hitB.center.set(
        controllerB.position.x,
        controllerB.position.y,
        controllerB.position.z,
      );
      cubeHit.center.set(cube.position.x, cube.position.y, cube.position.z);

      if (hitA.intersectsSphere(cubeHit) || hitB.intersectsSphere(cubeHit)) {
        isHitRef.current = true;
      } else {
        isHitRef.current = false;
      }

      // 衝突していたら、Cubeのサイズをちょっと小さくする
      if (isHitRef.current) {
        if (0.8 < cube.scale.x) {
          cube.scale.x -= 0.01;
          cube.scale.y -= 0.01;
          cube.scale.z -= 0.01;
        } else {
          cube.scale.x = 0.8;
          cube.scale.y = 0.8;
          cube.scale.z = 0.8;

          setIsHit(true);
        }
      } else {
        if (cube.scale.x < 1) {
          cube.scale.x += 0.01;
          cube.scale.y += 0.01;
          cube.scale.z += 0.01;
        } else {
          cube.scale.x = 1;
          cube.scale.y = 1;
          cube.scale.z = 1;

          setIsHit(false);
        }
      }
    });
  }, [renderer, camera, scene]);

  useEffect(() => {
    if (isHit) {
      hit();
    }
  }, [isHit]);

  function hit() {
    // Cubeに触れたときの処理を書く
    console.log('hit');
  }

  return <canvas ref={canvasRef} />;
}

めちゃめちゃ愚直に書きました。
今回は以上なのですが、以下に関連リンクを記載しておきます。

関連リンク

直線上にオブジェクトがあるかの判定

blog.kimizuka.org

オブジェクトのクリック判定

blog.kimizuka.org

オブジェクト同士の接触判定

blog.kimizuka.org

VRコントローラーとオブジェクトとの接触判定

blog.kimizuka.org