この記事は「C3 Advent Calendar 2024」20日目の記事です。
前書き(略可)
皆様こんにちは、C3 Advent Calendar 20日担当のSemikoronだよ!
さてさて皆さん、まず最初にVRヘッドセット持っていますでしょうか?最近(執筆当時)はMeta quest 3sの登場やVRChatの流行りもありつつ少しずつVRヘッドセットが普及してきています。
そんなVRヘッドセットしっかりとブラウザ機能を備えており、WebXRというWeb上で作り出すVR/ARコンテンツなんてものも沢山あるんですねぇ~~。
という事で今回は、手元にVRヘッドセットあるけど今まで開発難しそうだから手が出せなかったという人向けに射的ゲームが作れる記事を書かせていただきました~~!
完成品(略可)
確認したい方はこちらをご覧ください。
※必ずVR上のブラウザでアクセスしてください!
https://simple-shooting-game.vercel.app/
リポジトリ
https://github.com/Semi-koron/R3FSimpleShootingGame
環境構築(プロジェクト作成まで)
~ご用意頂く環境~
- Node.js
- npm
- vscode <- 本記事では一部エディタのコマンドを利用しておりますが、無視してもらって構いません
- React.js(vite)
- google chrome <- VRでローカルサーバーにアクセスしやすい
- 開発者モードがオンになったVR
またコードエディタの環境構築はこちらの私の記事を参考にして下さい!
こちらの記事はNextのプロジェクトを作成しているので、
必ずviteでReactのプロジェクトを作成するようにしてください。
https://qiita.com/Semikoron/items/2f09e4fc3c2d12c14b12
viteでReactのプロジェクトを作製
以下のようなコードをターミナル上で実行します。
(npmのバージョンが7以上であれば追加で 2 つのダッシュが必要です!)
npm create vite@latest r3fxr-project -- --template react-swc-ts
実行完了したら、プロジェクトのディレクトリに移動します。
cd r3fxr-project
npm install
code .
移動のついでにパッケージのインストールのコマンドも実行されているはずなので、
新しいウィンドウのエディタからターミナルを起動し、以下のコードを実行することによって
ローカルサーバー(開発環境)が立ち上がります。
こちらのコマンドはよく使うので覚えておいてください。
npm run dev
ローカルサーバーが立ち上がったら以下のURLにアクセスしてもらうと
http://localhost:5173/
このような画面が起動すると思います!
このような画面が起動すれば、プロジェクトの準備は完了です!
ここまで終えた方は一度ターミナルで停止(ctrlキー+cキー)の操作を行い、
ローカルサーバーを閉じましょう。
要らないファイルを削除
プロジェクトを作成すると必ずデフォルトのページが追加されるので、必要のないものを消していきます!
以下のファイルにコードをコピーペーストして下さい。
import "./App.css";
function App() {
return (
<>
<h1>Hello World</h1>
</>
);
}
export default App;
#root {
margin: 0 auto;
text-align: center;
}
R3F関連のパッケージを入れていく
以下のコマンドをターミナル上で実行します
npm install three @react-three/fiber @react-three/xr@latest @react-three/drei @react-three/rapier
npm install --save-dev @types/react
これにより、Reactで3D描画ができる環境が用意できます!
ReactThreeFiberについて(省略可)
Web上での3D描画には必須のライブラリThreejsがReactでも使えちゃうという凄いライブラリ。
本記事ではReactThreeFiberはR3Fと一部略して記述しています。
初めての3Dシーンを作ってみる
パッケージをインストールしたことによって
以下のコードを記述しているファイルにコピペすることで、
Web上で3D描画ができます!
import "./App.css";
import { Canvas } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
import * as THREE from "three";
function App() {
return (
<>
<Canvas
camera={{
position: [0, 5, 8],
fov: 50,
near: 0.1,
far: 2000,
}}
dpr={window.devicePixelRatio}
shadows
style={{ width: "100vw", height: "100vh" }}
>
{/* 背景色 */}
<color attach="background" args={["#DDDDDD"]} />
{/* カメラの操作 */}
<OrbitControls />
{/* ライトの設定 */}
<ambientLight intensity={0.1} />
<directionalLight
position={[2, 6, 4]}
intensity={1}
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
castShadow
/>
{/* オブジェクト */}
<mesh position={[0, 1, 0]} castShadow>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#f00" />
</mesh>
{/* 床 */}
<Plane rotation={[-Math.PI / 2, 0, 0]} args={[10, 10]} receiveShadow>
<meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
</Plane>
</Canvas>
</>
);
}
export default App;
ローカルサーバーを起動して、
localhost:5173にアクセスすると…
しっかりと3D描画できていますね!
React Three fiberには合わせて使えるライブラリがたくさんあり、
今回はreact-three/dreiを使うことで、簡単にマウスでカメラを動かす機能を実装しています。
物理エンジンを入れていく
さて次は物理エンジンを実装していきましょう!物理エンジン…難しそう…とお思いの方も多いと思います。
しかしですね、今回利用するrapierというライブラリで簡単に物理エンジンが実装できてしまうのです!
という事で物理エンジンを実装して、キューブに重力を与えてみましょう!
差分コード
import "./App.css";
import { Canvas } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
+ import { Physics, RigidBody } from "@react-three/rapier";
import * as THREE from "three";
function App() {
return (
<>
<Canvas
camera={{
position: [0, 5, 8],
fov: 50,
near: 0.1,
far: 2000,
}}
dpr={window.devicePixelRatio}
shadows
style={{ width: "100vw", height: "100vh" }}
>
{/* 背景色 */}
<color attach="background" args={["#DDDDDD"]} />
{/* カメラの操作 */}
<OrbitControls />
{/* ライトの設定 */}
<ambientLight intensity={0.1} />
<directionalLight
position={[2, 6, 4]}
intensity={1}
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
castShadow
/>
{/* オブジェクト */}
+ <Physics gravity={[0, -9.81, 0]}>
+ <RigidBody type="dynamic">
<mesh position={[0, 1, 0]} castShadow>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#f00" />
</mesh>
+ </RigidBody>
+ <RigidBody type="fixed">
{/* 床 */}
<Plane
rotation={[-Math.PI / 2, 0, 0]}
args={[10, 10]}
receiveShadow
>
<meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
</Plane>
+ </RigidBody>
+ </Physics>
</Canvas>
</>
);
}
export default App;
コピペ用ソースコード
import "./App.css";
import { Canvas } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
import { Physics, RigidBody } from "@react-three/rapier";
import * as THREE from "three";
function App() {
return (
<>
<Canvas
camera={{
position: [0, 5, 8],
fov: 50,
near: 0.1,
far: 2000,
}}
dpr={window.devicePixelRatio}
shadows
style={{ width: "100vw", height: "100vh" }}
>
{/* 背景色 */}
<color attach="background" args={["#DDDDDD"]} />
{/* カメラの操作 */}
<OrbitControls />
{/* ライトの設定 */}
<ambientLight intensity={0.1} />
<directionalLight
position={[2, 6, 4]}
intensity={1}
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
castShadow
/>
{/* オブジェクト */}
<Physics gravity={[0, -9.81, 0]}>
<RigidBody type="dynamic">
<mesh position={[0, 1, 0]} castShadow>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#f00" />
</mesh>
</RigidBody>
<RigidBody type="fixed">
{/* 床 */}
<Plane
rotation={[-Math.PI / 2, 0, 0]}
args={[10, 10]}
receiveShadow
>
<meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
</Plane>
</RigidBody>
</Physics>
</Canvas>
</>
);
}
export default App;
こうすることによってローカルサーバーで確認をしてみると…
あっという間に物理エンジンが完成しましたね!
VRでシーンに入る
VRでローカルサーバーにアクセスする
まずはVRヘッドセットとパソコンを接続しましょう。
今回私が使用しているのはmeta quest3なので、meta quest3ベースで話を進めさせていただきます。
おそらく接続した際にPCからのアクセスを許可するかどうかの通知がVRヘッドセット上で表示されると思います。
表示されたら許可を押してください。
またここではgoogle chromeを使用してもらうことになるのですが、
chrome://inspect/#devices
へアクセスしてください。
すると以下のような画面が表示されると思います。
この画面の「Port fowarding...」を押すと以下のような設定画面が出てくると思います。
こちらのPortに5173とIp address and portにlocalhost:5173を設定し、
「Done」を押します。
するとVRヘッドセット上のブラウザでローカルサーバーにアクセスできます。
VRでシーンに入れる機能を作る
VRで作成したシーンに入るにはライブラリのthree-react/xrを使用します。
たったXRのstoreの初期化を行って、Canvasタグ配下をXRタグで覆ってあげることで、
VRでシーンに入る準備はほぼ完了です。
以下のコードを記述しているファイルにコピペすることで、
VRヘッドセットからアクセスした際にVRでシーンに入ることが可能になります。
差分コード
import "./App.css";
import { Canvas } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
import { Physics, RigidBody } from "@react-three/rapier";
+ import { createXRStore, XR } from "@react-three/xr";
import * as THREE from "three";
+ const store = createXRStore();
function App() {
return (
<>
+ <button
+ onClick={() => store.enterVR()}
+ style={{
+ position: "fixed",
+ top: "10px",
+ left: "10px",
+ zIndex: 1000,
+ height: "100px",
+ width: "200px",
+ }}
+ >
+ Enter VR
+ </button>
<Canvas
camera={{
position: [0, 5, 8],
fov: 50,
near: 0.1,
far: 2000,
}}
dpr={window.devicePixelRatio}
shadows
style={{ width: "100vw", height: "100vh" }}
>
+ <XR store={store}>
{/* 背景色 */}
<color attach="background" args={["#DDDDDD"]} />
{/* カメラの操作 */}
<OrbitControls />
{/* ライトの設定 */}
<ambientLight intensity={0.1} />
<directionalLight
position={[2, 6, 4]}
intensity={1}
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
castShadow
/>
{/* オブジェクト */}
<Physics gravity={[0, -9.81, 0]}>
<RigidBody type="dynamic">
<mesh position={[0, 1, 0]} castShadow>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#f00" />
</mesh>
</RigidBody>
<RigidBody type="fixed">
{/* 床 */}
<Plane
rotation={[-Math.PI / 2, 0, 0]}
args={[10, 10]}
receiveShadow
>
<meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
</Plane>
</RigidBody>
</Physics>
+ </XR>
</Canvas>
</>
);
}
export default App;
コピペ用ソースコード
import "./App.css";
import { Canvas } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
import { Physics, RigidBody } from "@react-three/rapier";
import { createXRStore, XR } from "@react-three/xr";
import * as THREE from "three";
const store = createXRStore();
function App() {
return (
<>
<button
onClick={() => store.enterVR()}
style={{
position: "fixed",
top: "10px",
left: "10px",
zIndex: 1000,
height: "100px",
width: "200px",
}}
>
Enter VR
</button>
<Canvas
camera={{
position: [0, 5, 8],
fov: 50,
near: 0.1,
far: 2000,
}}
dpr={window.devicePixelRatio}
shadows
style={{ width: "100vw", height: "100vh" }}
>
<XR store={store}>
{/* 背景色 */}
<color attach="background" args={["#DDDDDD"]} />
{/* カメラの操作 */}
<OrbitControls />
{/* ライトの設定 */}
<ambientLight intensity={0.1} />
<directionalLight
position={[2, 6, 4]}
intensity={1}
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
castShadow
/>
{/* オブジェクト */}
<Physics gravity={[0, -9.81, 0]}>
<RigidBody type="dynamic">
<mesh position={[0, 1, 0]} castShadow>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#f00" />
</mesh>
</RigidBody>
<RigidBody type="fixed">
{/* 床 */}
<Plane
rotation={[-Math.PI / 2, 0, 0]}
args={[10, 10]}
receiveShadow
>
<meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
</Plane>
</RigidBody>
</Physics>
</XR>
</Canvas>
</>
);
}
export default App;
実際にmeta quest 3のブラウザ上からアクセスしてみる。
「Enter VR」ボタンを押すことによって
キューブを発射する機能を作成する
さてさていい感じなってきたので次はコントローラーの操作を追加してみましょう!
コントローラーの操作を可能にするにはXRタグの配下にXROriginタグを追加し、
useXRInputSourceState
を利用することで、コントローラーの操作を読み取れます。
以下のコードをコピペすることで、
コントローラーのグリップボタンを押すことでキューブが自分の手に移動します!
差分コード
import "./App.css";
- import { Canvas } from "@react-three/fiber";
+ import { Canvas, useFrame } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
- import { Physics, RigidBody } from "@react-three/rapier";
+ import { Physics, RigidBody, RapierRigidBody } from "@react-three/rapier";
- import { createXRStore, XR } from "@react-three/xr";
+ import {
+ createXRStore,
+ XR,
+ XROrigin,
+ useXRInputSourceState,
+ } from "@react-three/xr";
+ import { useRef } from "react";
import * as THREE from "three";
const store = createXRStore();
function App() {
+ const ref = useRef<THREE.Group>(null);
+ const cubeRef = useRef<RapierRigidBody>(null);
+
+ const shoot = (direction: THREE.Quaternion, position: THREE.Vector3) => {
+ // キューブを速度0に
+ cubeRef.current?.setLinvel(
+ {
+ x: 0,
+ y: 0,
+ z: 0,
+ },
+ true
+ );
+ // キューブの位置を変更
+ cubeRef.current?.setTranslation(
+ {
+ x: position.x,
+ y: position.y,
+ z: position.z,
+ },
+ true
+ );
+
+ // キューブの向きを変更
+ cubeRef.current?.setRotation(
+ {
+ x: direction.x,
+ y: direction.y,
+ z: direction.z,
+ w: direction.w,
+ },
+ true
+ );
+
+ // 向きに応じて力を加える
+ const force = new THREE.Vector3(0, 0, -0.1);
+ force.applyQuaternion(direction);
+ cubeRef.current?.applyImpulse(
+ {
+ x: force.x,
+ y: force.y,
+ z: force.z,
+ },
+ true
+ );
+ };
+
+ const Locomotion = () => {
+ const leftController = useXRInputSourceState("controller", "left");
+ const rightController = useXRInputSourceState("controller", "right");
+ let isGrabbing = false;
+ useFrame(() => {
+ const leftObject = leftController?.object;
+ const rightObject = rightController?.object;
+ if (leftController && rightController) {
+ const leftSqueezeState = leftController.gamepad["xr-standard-squeeze"];
+ const rightSqueezeState =
+ rightController.gamepad["xr-standard-squeeze"];
+ if (!isGrabbing) {
+ if (leftObject && leftSqueezeState?.state == "pressed") {
+ // 左コントローラーの処理
+ isGrabbing = true;
+ const leftPosition = new THREE.Vector3();
+ leftObject.getWorldPosition(leftPosition);
+ const leftDirection = new THREE.Quaternion();
+ leftObject.getWorldQuaternion(leftDirection);
+ shoot(leftDirection, leftPosition);
+ }
+ if (rightObject && rightSqueezeState?.state == "pressed") {
+ // 右コントローラーの処理
+ isGrabbing = true;
+ const rightPosition = new THREE.Vector3();
+ rightObject.getWorldPosition(rightPosition);
+ const rightDirection = new THREE.Quaternion();
+ rightObject.getWorldQuaternion(rightDirection);
+ shoot(rightDirection, rightPosition);
+ }
+ }
+ //両手が離された時の処理
+ if (
+ rightSqueezeState?.state == "default" &&
+ leftSqueezeState?.state == "default"
+ ) {
+ isGrabbing = false;
+ }
+ }
+ });
+ return <XROrigin ref={ref} />;
+ };
return (
<>
<button
onClick={() => store.enterVR()}
style={{
position: "fixed",
top: "10px",
left: "10px",
zIndex: 1000,
height: "100px",
width: "200px",
}}
>
Enter VR
</button>
<Canvas
camera={{
position: [0, 5, 8],
fov: 50,
near: 0.1,
far: 2000,
}}
dpr={window.devicePixelRatio}
shadows
style={{ width: "100vw", height: "100vh" }}
>
<XR store={store}>
{/* 背景色 */}
<color attach="background" args={["#DDDDDD"]} />
{/* カメラの操作 */}
<OrbitControls />
{/* ライトの設定 */}
<ambientLight intensity={0.1} />
<directionalLight
position={[2, 6, 4]}
intensity={1}
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
castShadow
/>
{/* オブジェクト */}
<Physics gravity={[0, -9.81, 0]}>
- <RigidBody type="dynamic">
+ <RigidBody
+ type="dynamic"
+ ref={cubeRef}
+ position={[0, 1, 0]}
+ rotation={[0, 0, 0]}
+ >
<mesh position={[0, 1, 0]} castShadow>
- <boxGeometry args={[1, 1, 1]} />
+ <boxGeometry args={[0.2, 0.2, 0.2]} />
<meshStandardMaterial color="#f00" />
</mesh>
</RigidBody>
<RigidBody type="fixed">
{/* 床 */}
<Plane
rotation={[-Math.PI / 2, 0, 0]}
args={[10, 10]}
receiveShadow
>
<meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
</Plane>
</RigidBody>
</Physics>
+ <Locomotion />
</XR>
</Canvas>
</>
);
}
export default App;
コピペ用ソースコード
import "./App.css";
import { Canvas, useFrame } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
import { Physics, RigidBody, RapierRigidBody } from "@react-three/rapier";
import {
createXRStore,
XR,
XROrigin,
useXRInputSourceState,
} from "@react-three/xr";
import { useRef } from "react";
import * as THREE from "three";
const store = createXRStore();
function App() {
const ref = useRef<THREE.Group>(null);
const cubeRef = useRef<RapierRigidBody>(null);
const shoot = (direction: THREE.Quaternion, position: THREE.Vector3) => {
// キューブを速度0に
cubeRef.current?.setLinvel(
{
x: 0,
y: 0,
z: 0,
},
true
);
// キューブの位置を変更
cubeRef.current?.setTranslation(
{
x: position.x,
y: position.y,
z: position.z,
},
true
);
// キューブの向きを変更
cubeRef.current?.setRotation(
{
x: direction.x,
y: direction.y,
z: direction.z,
w: direction.w,
},
true
);
// 向きに応じて力を加える
const force = new THREE.Vector3(0, 0, -0.1);
force.applyQuaternion(direction);
cubeRef.current?.applyImpulse(
{
x: force.x,
y: force.y,
z: force.z,
},
true
);
};
const Locomotion = () => {
const leftController = useXRInputSourceState("controller", "left");
const rightController = useXRInputSourceState("controller", "right");
let isGrabbing = false;
useFrame(() => {
const leftObject = leftController?.object;
const rightObject = rightController?.object;
if (leftController && rightController) {
const leftSqueezeState = leftController.gamepad["xr-standard-squeeze"];
const rightSqueezeState =
rightController.gamepad["xr-standard-squeeze"];
if (!isGrabbing) {
if (leftObject && leftSqueezeState?.state == "pressed") {
// 左コントローラーの処理
isGrabbing = true;
const leftPosition = new THREE.Vector3();
leftObject.getWorldPosition(leftPosition);
const leftDirection = new THREE.Quaternion();
leftObject.getWorldQuaternion(leftDirection);
shoot(leftDirection, leftPosition);
}
if (rightObject && rightSqueezeState?.state == "pressed") {
// 右コントローラーの処理
isGrabbing = true;
const rightPosition = new THREE.Vector3();
rightObject.getWorldPosition(rightPosition);
const rightDirection = new THREE.Quaternion();
rightObject.getWorldQuaternion(rightDirection);
shoot(rightDirection, rightPosition);
}
}
//両手が離された時の処理
if (
rightSqueezeState?.state == "default" &&
leftSqueezeState?.state == "default"
) {
isGrabbing = false;
}
}
});
return <XROrigin ref={ref} />;
};
return (
<>
<button
onClick={() => store.enterVR()}
style={{
position: "fixed",
top: "10px",
left: "10px",
zIndex: 1000,
height: "100px",
width: "200px",
}}
>
Enter VR
</button>
<Canvas
camera={{
position: [0, 5, 8],
fov: 50,
near: 0.1,
far: 2000,
}}
dpr={window.devicePixelRatio}
shadows
style={{ width: "100vw", height: "100vh" }}
>
<XR store={store}>
{/* 背景色 */}
<color attach="background" args={["#DDDDDD"]} />
{/* カメラの操作 */}
<OrbitControls />
{/* ライトの設定 */}
<ambientLight intensity={0.1} />
<directionalLight
position={[2, 6, 4]}
intensity={1}
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
castShadow
/>
{/* オブジェクト */}
<Physics gravity={[0, -9.8, 0]}>
<RigidBody
type="dynamic"
ref={cubeRef}
position={[0, 1, 0]}
rotation={[0, 0, 0]}
>
<mesh castShadow>
<boxGeometry args={[0.2, 0.2, 0.2]} />
<meshStandardMaterial color="#f00" />
</mesh>
</RigidBody>
<RigidBody type="fixed">
{/* 床 */}
<Plane
rotation={[-Math.PI / 2, 0, 0]}
args={[10, 10]}
receiveShadow
>
<meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
</Plane>
</RigidBody>
</Physics>
<Locomotion />
</XR>
</Canvas>
</>
);
}
export default App;
コピーペーストを行ってVR上でコントローラーのグラップボタンを押すことで
キューブが発射されました!!
的を作成する
最後に的を作成していきましょう!
的とキューブが当たったという衝突判定を作るときもreact-three/rapierを使用します。
以下のコードのように衝突判定による的移動を作成することによって、
簡易的な射的ゲームが完成します。
差分コード
import "./App.css";
import { Canvas, useFrame } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
import { Physics, RigidBody, RapierRigidBody } from "@react-three/rapier";
import {
createXRStore,
XR,
XROrigin,
useXRInputSourceState,
} from "@react-three/xr";
import { useRef } from "react";
import * as THREE from "three";
const store = createXRStore();
function App() {
const ref = useRef<THREE.Group>(null);
const cubeRef = useRef<RapierRigidBody>(null);
+ const targetRef = useRef<RapierRigidBody>(null);
const shoot = (direction: THREE.Quaternion, position: THREE.Vector3) => {
// キューブを速度0に
cubeRef.current?.setLinvel(
{
x: 0,
y: 0,
z: 0,
},
true
);
// キューブの位置を変更
cubeRef.current?.setTranslation(
{
x: position.x,
y: position.y,
z: position.z,
},
true
);
// キューブの向きを変更
cubeRef.current?.setRotation(
{
x: direction.x,
y: direction.y,
z: direction.z,
w: direction.w,
},
true
);
// 向きに応じて力を加える
const force = new THREE.Vector3(0, 0, -0.1);
force.applyQuaternion(direction);
cubeRef.current?.applyImpulse(
{
x: force.x,
y: force.y,
z: force.z,
},
true
);
};
const Locomotion = () => {
const leftController = useXRInputSourceState("controller", "left");
const rightController = useXRInputSourceState("controller", "right");
let isGrabbing = false;
useFrame(() => {
const leftObject = leftController?.object;
const rightObject = rightController?.object;
if (leftController && rightController) {
const leftSqueezeState = leftController.gamepad["xr-standard-squeeze"];
const rightSqueezeState =
rightController.gamepad["xr-standard-squeeze"];
if (!isGrabbing) {
if (leftObject && leftSqueezeState?.state == "pressed") {
// 左コントローラーの処理
isGrabbing = true;
const leftPosition = new THREE.Vector3();
leftObject.getWorldPosition(leftPosition);
const leftDirection = new THREE.Quaternion();
leftObject.getWorldQuaternion(leftDirection);
shoot(leftDirection, leftPosition);
}
if (rightObject && rightSqueezeState?.state == "pressed") {
// 右コントローラーの処理
isGrabbing = true;
const rightPosition = new THREE.Vector3();
rightObject.getWorldPosition(rightPosition);
const rightDirection = new THREE.Quaternion();
rightObject.getWorldQuaternion(rightDirection);
shoot(rightDirection, rightPosition);
}
}
//両手が離された時の処理
if (
rightSqueezeState?.state == "default" &&
leftSqueezeState?.state == "default"
) {
isGrabbing = false;
}
}
});
return <XROrigin ref={ref} />;
};
return (
<>
<button
onClick={() => store.enterVR()}
style={{
position: "fixed",
top: "10px",
left: "10px",
zIndex: 1000,
height: "100px",
width: "200px",
}}
>
Enter VR
</button>
<Canvas
camera={{
position: [0, 5, 8],
fov: 50,
near: 0.1,
far: 2000,
}}
dpr={window.devicePixelRatio}
shadows
style={{ width: "100vw", height: "100vh" }}
>
<XR store={store}>
{/* 背景色 */}
<color attach="background" args={["#DDDDDD"]} />
{/* カメラの操作 */}
<OrbitControls />
{/* ライトの設定 */}
<ambientLight intensity={0.1} />
<directionalLight
position={[2, 6, 4]}
intensity={1}
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
castShadow
/>
{/* オブジェクト */}
<Physics gravity={[0, -9.8, 0]}>
<RigidBody
type="dynamic"
+ name="bullet"
ref={cubeRef}
position={[0, 1, 0]}
rotation={[0, 0, 0]}
>
<mesh castShadow>
<boxGeometry args={[0.2, 0.2, 0.2]} />
<meshStandardMaterial color="#f00" />
</mesh>
</RigidBody>
+ <RigidBody
+ type="dynamic"
+ ref={targetRef}
+ onCollisionEnter={(event) => {
+ if (
+ event.colliderObject &&
+ event.colliderObject.name === "bullet"
+ ) {
+ targetRef.current?.setTranslation(
+ {
+ x: Math.random() * 5 - 2.5,
+ y: 6,
+ z: Math.random() * 5 - 2.5,
+ },
+ true
+ );
+ }
+ }}
+ position={[0, 6, -2]}
+ rotation={[0, 0, 0]}
+ >
+ <mesh castShadow>
+ <boxGeometry args={[0.5, 0.5, 0.5]} />
+ <meshStandardMaterial color="#00f" />
+ </mesh>
+ </RigidBody>
<RigidBody type="fixed">
{/* 床 */}
<Plane
rotation={[-Math.PI / 2, 0, 0]}
args={[10, 10]}
receiveShadow
>
<meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
</Plane>
</RigidBody>
</Physics>
<Locomotion />
</XR>
</Canvas>
</>
);
}
export default App;
コピペ用ソースコード
import "./App.css";
import { Canvas, useFrame } from "@react-three/fiber";
import { OrbitControls, Plane } from "@react-three/drei";
import { Physics, RigidBody, RapierRigidBody } from "@react-three/rapier";
import {
createXRStore,
XR,
XROrigin,
useXRInputSourceState,
} from "@react-three/xr";
import { useRef } from "react";
import * as THREE from "three";
const store = createXRStore();
function App() {
const ref = useRef<THREE.Group>(null);
const cubeRef = useRef<RapierRigidBody>(null);
const targetRef = useRef<RapierRigidBody>(null);
const shoot = (direction: THREE.Quaternion, position: THREE.Vector3) => {
// キューブを速度0に
cubeRef.current?.setLinvel(
{
x: 0,
y: 0,
z: 0,
},
true
);
// キューブの位置を変更
cubeRef.current?.setTranslation(
{
x: position.x,
y: position.y,
z: position.z,
},
true
);
// キューブの向きを変更
cubeRef.current?.setRotation(
{
x: direction.x,
y: direction.y,
z: direction.z,
w: direction.w,
},
true
);
// 向きに応じて力を加える
const force = new THREE.Vector3(0, 0, -0.1);
force.applyQuaternion(direction);
cubeRef.current?.applyImpulse(
{
x: force.x,
y: force.y,
z: force.z,
},
true
);
};
const Locomotion = () => {
const leftController = useXRInputSourceState("controller", "left");
const rightController = useXRInputSourceState("controller", "right");
let isGrabbing = false;
useFrame(() => {
const leftObject = leftController?.object;
const rightObject = rightController?.object;
if (leftController && rightController) {
const leftSqueezeState = leftController.gamepad["xr-standard-squeeze"];
const rightSqueezeState =
rightController.gamepad["xr-standard-squeeze"];
if (!isGrabbing) {
if (leftObject && leftSqueezeState?.state == "pressed") {
// 左コントローラーの処理
isGrabbing = true;
const leftPosition = new THREE.Vector3();
leftObject.getWorldPosition(leftPosition);
const leftDirection = new THREE.Quaternion();
leftObject.getWorldQuaternion(leftDirection);
shoot(leftDirection, leftPosition);
}
if (rightObject && rightSqueezeState?.state == "pressed") {
// 右コントローラーの処理
isGrabbing = true;
const rightPosition = new THREE.Vector3();
rightObject.getWorldPosition(rightPosition);
const rightDirection = new THREE.Quaternion();
rightObject.getWorldQuaternion(rightDirection);
shoot(rightDirection, rightPosition);
}
}
//両手が離された時の処理
if (
rightSqueezeState?.state == "default" &&
leftSqueezeState?.state == "default"
) {
isGrabbing = false;
}
}
});
return <XROrigin ref={ref} />;
};
return (
<>
<button
onClick={() => store.enterVR()}
style={{
position: "fixed",
top: "10px",
left: "10px",
zIndex: 1000,
height: "100px",
width: "200px",
}}
>
Enter VR
</button>
<Canvas
camera={{
position: [0, 5, 8],
fov: 50,
near: 0.1,
far: 2000,
}}
dpr={window.devicePixelRatio}
shadows
style={{ width: "100vw", height: "100vh" }}
>
<XR store={store}>
{/* 背景色 */}
<color attach="background" args={["#DDDDDD"]} />
{/* カメラの操作 */}
<OrbitControls />
{/* ライトの設定 */}
<ambientLight intensity={0.1} />
<directionalLight
position={[2, 6, 4]}
intensity={1}
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
castShadow
/>
{/* オブジェクト */}
<Physics gravity={[0, -9.8, 0]}>
<RigidBody
type="dynamic"
name="bullet"
ref={cubeRef}
position={[0, 1, 0]}
rotation={[0, 0, 0]}
>
<mesh castShadow>
<boxGeometry args={[0.2, 0.2, 0.2]} />
<meshStandardMaterial color="#f00" />
</mesh>
</RigidBody>
<RigidBody
type="dynamic"
ref={targetRef}
onCollisionEnter={(event) => {
if (
event.colliderObject &&
event.colliderObject.name === "bullet"
) {
targetRef.current?.setTranslation(
{
x: Math.random() * 5 - 2.5,
y: 6,
z: Math.random() * 5 - 2.5,
},
true
);
}
}}
position={[0, 6, -2]}
rotation={[0, 0, 0]}
>
<mesh castShadow>
<boxGeometry args={[0.5, 0.5, 0.5]} />
<meshStandardMaterial color="#00f" />
</mesh>
</RigidBody>
<RigidBody type="fixed">
{/* 床 */}
<Plane
rotation={[-Math.PI / 2, 0, 0]}
args={[10, 10]}
receiveShadow
>
<meshStandardMaterial color="#fff" side={THREE.DoubleSide} />
</Plane>
</RigidBody>
</Physics>
<Locomotion />
</XR>
</Canvas>
</>
);
}
export default App;
これにて射的ゲーム完成です!!
休憩に無限に生成される青いキューブを壊してみましょう!
まとめ
A-frame、Babylon.jsなど競合のWebXRのライブラリはたくさんあるのだが、ReactThreeFiberの良さと言えば、合わせて使えるライブラリがかなーりたくさんあるため実装が凄く楽です。
若干react-three/xrの公式ドキュメントの記述内容が少ない点は少し残念ですが、僕が使ってきた中で一番といってもいい程Reactとの相性は抜群です。
最後にここまで読んでくれてありがとうございました。
この記事で少しでもVRに興味を持っていただけると幸いです。
明日は僕(@boku_me)さんの「あなたに送る難解プログラミング言語のクリスマスプレゼント」です。お楽しみに!