2022年はWebVR、2023年はWebARに関する知見をまとめたりしていましたが、久しぶりにWebVRコンテンツを作ってみました。
内容としては、
- 目の前に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 バージョンも試作してみましたが、驚くほど簡単につくることができました)
リポジトリ
ソースコード
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で立ち上げられるようにしておきます。
なぜならば、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 を使っています。
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} />; }
めちゃめちゃ愚直に書きました。
今回は以上なのですが、以下に関連リンクを記載しておきます。