概要
一人称視点でのダンジョン徘徊系のゲームのように、前進するたびに奥へ奥へと進んでいく体験を React Router + react-three/fiber で実装しました。
遠近法で描かれたグリッドの部屋を通り抜けていく感覚を、ボタン押下によるページ遷移で表現します。
react-three/fiber初学者向けに、今回使ったreact-three/fiber特有の要素の説明も一緒にしていきます。
three.jsやreact-three/fiberの公式ドキュメントは基本的に英語であるため、学習の一助になれば幸いです。
実装するもの
- グリッドで区切られた部屋(床・天井・壁)
- ボタン押下によるページ遷移で、カメラが前進するアニメーション
技術スタック
- Node.js 24.11
- Vite 7.2
- React 19: UI フレームワーク
- Three.js 0.181: 3D グラフィックスライブラリ
- @react-three/fiber 9.4: Three.js を React で扱うためのレンダラー
- @react-three/drei 10.7: Three.js のヘルパーライブラリ
- React Router DOM 7.9: ページ遷移管理
- TypeScript 5.9: 型安全な開発
react-three/fiber を選んだ理由
- 宣言的な JSX で React らしく書ける
- canvas の DOM 操作が自動化されている
- メモリ管理(クリーンアップ)が自動
- useFrame フックでアニメーションループが簡潔
グリッドルームの実装
部屋の構造
各部屋は以下の要素で構成されます
interface GridRoomProps {
position: [number, number, number]; // [X, Y, Z] 座標。X: 左右, Y: 上下, Z: 奥行き
}
function GridRoom({ position }: GridRoomProps) {
return (
<group position={position}>
{/* 床のグリッド */}
<gridHelper args={[20, 10, 0xffffff, 0xffffff]} position={[0, -5, 0]} />
{/* 天井のグリッド(X軸で反転) */}
<gridHelper args={[20, 10, 0xffffff, 0xffffff]} position={[0, 5, 0]} rotation={[Math.PI, 0, 0]} />
{/* 左右の壁(ワイヤーフレーム) */}
<mesh position={[-10, 0, 0]} rotation={[0, Math.PI / 2, 0]}>
<planeGeometry args={[20, 10, 10, 5]} />
<meshBasicMaterial color={0xffffff} wireframe transparent opacity={0.5} />
</mesh>
<mesh position={[10, 0, 0]} rotation={[0, -Math.PI / 2, 0]}>
<planeGeometry args={[20, 10, 10, 5]} />
<meshBasicMaterial color={0xffffff} wireframe transparent opacity={0.5} />
</mesh>
</group>
);
}
各要素の説明
-
<group>: 複数の3Dオブジェクトをまとめて管理するコンテナ。position や rotation をまとめて適用できる -
<gridHelper>: グリッド線を表示するヘルパー。args は[サイズ, 分割数, 中心線の色, グリッド線の色] -
<mesh>: 3Dオブジェクトのコンテナ。ジオメトリ(形状)とマテリアル(質感)を組み合わせる -
<planeGeometry>: 平面の形状を定義。args は[幅, 高さ, 幅の分割数, 高さの分割数] -
<meshBasicMaterial>: 光源の影響を受けないシンプルなマテリアル(後述のambientLightやpointLightを無視できる)。wireframeでワイヤーフレーム表示
カメラアニメーション
前進する感覚を表現するため、カメラを Z 軸方向に移動させます
interface SceneProps {
depth: number; // 現在のレベル(部屋の深さ)
onComplete?: () => void; // アニメーション完了時のコールバック関数
}
function Scene({ depth, onComplete }: SceneProps) {
const [hasCompleted, setHasCompleted] = useState(false);
const startZ = 100 - (depth - 1) * 60; // レベルごとの開始位置
const targetZ = startZ - 60; // 60ユニット前進
useFrame((state) => {
const camera = state.camera;
if (camera.position.z > targetZ + 0.5) {
camera.position.z -= 0.8; // 前進速度
camera.lookAt(0, 0, camera.position.z - 50);
} else if (!hasCompleted) {
// アニメーション完了
setHasCompleted(true);
if (onComplete) {
setTimeout(onComplete, 300);
}
}
});
return (
<>
<fog attach="fog" args={[0x000000, 20, 200]} />
<ambientLight intensity={0.6} />
<pointLight position={[0, 10, startZ]} />
{/* 35個の部屋を配置 */}
{Array.from({ length: 35 }, (_, i) => (
<GridRoom key={i} position={[0, 0, (i - 20) * 20]} />
))}
</>
);
}
各要素の説明
-
<fog>: 距離に応じて霧がかかるエフェクト。args は[色, 開始距離, 終了距離]。奥行き感を演出 -
<ambientLight>: 全体を均等に照らす環境光。影を作らない基礎的な明るさ -
<pointLight>: 特定位置から全方向に光を放つ点光源。position でカメラの前方を照らすように設定
Canvas とカメラの設定
Canvas コンポーネントでカメラとレンダラーの設定を行います
interface GridRoomSceneFiberProps {
depth: number; // 現在のレベル(部屋の深さ)
onComplete?: () => void; // アニメーション完了時のコールバック関数
}
export function GridRoomSceneFiber({ depth, onComplete }: GridRoomSceneFiberProps) {
return (
<div style={{ width: '100%', height: '100vh', position: 'absolute', top: 0, left: 0 }}>
<Canvas
camera={{
position: [0, 0, 100 - (depth - 1) * 60], // depth に応じた初期位置
fov: 75, // 視野角
near: 0.1, // 近クリップ面
far: 1000 // 遠クリップ面
}}
gl={{
antialias: true // アンチエイリアス有効
}}
style={{ background: '#000000' }}
>
<Scene depth={depth} onComplete={onComplete} />
</Canvas>
</div>
);
}
各設定の説明
-
camera.position: レベルごとにカメラの開始位置を調整(レベル1: Z=100, レベル2: Z=40...) -
camera.fov: 視野角75度。広角で臨場感を演出 -
camera.near/far: 描画範囲の制限。near(近クリップ面)より近い物体と far(遠クリップ面)より遠い物体は描画されない。パフォーマンス最適化に有効 -
gl.antialias: エッジを滑らかに表示
ルーティングとレベル管理
React Router を使って複数のレベルを管理します
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="/room/1" replace />} />
<Route path="/room/:level" element={<RoomWrapper />} />
</Routes>
</BrowserRouter>
);
}
function RoomWrapper() {
const { level } = useParams<{ level: string }>();
const navigate = useNavigate();
const [showButton, setShowButton] = useState<boolean>(false);
const handleAnimationComplete = useCallback(() => {
setShowButton(true);
}, []);
const handleNextRoom = () => {
setShowButton(false);
navigate(`/room/${parseInt(level || '1', 10) + 1}`);
};
return (
<div className="room-container">
<GridRoomSceneFiber
depth={parseInt(level || '1', 10)}
onComplete={handleAnimationComplete}
/>
{showButton && (
<button onClick={handleNextRoom}>
次の部屋へ進む →
</button>
)}
</div>
);
}
開発で遭遇した問題と解決策
問題: アニメーション中にコンポーネントが再マウントされる
症状
- カメラのアニメーションが途中で停止・再開する
- ボタン出現時にアニメーションがリスタートする
原因
アニメーション完了時の onComplete コールバックで親の状態が更新され、以下の理由で子コンポーネントが再マウントされていました
-
key props による強制再マウント:
<Component key={level} />のように指定していると、同じlevelでも再マウントされる - コールバック関数の再生成: 毎回新しい関数参照が渡されると props 変更として検出される
解決策
1. key props の削除
// ❌ 悪い例: 同じlevelでも再マウントされる
<GridRoomScene key={level} depth={level} onComplete={handleAnimationComplete} />
// ✅ 良い例: propsが同じなら再マウントしない
<GridRoomScene depth={level} onComplete={handleAnimationComplete} />
2. useCallback によるメモ化
import { useState, useCallback } from "react";
const handleAnimationComplete = useCallback(() => {
setShowButton(true);
}, []); // 依存配列が空なので、関数参照は初回レンダー時のみ作成される
これにより、不要な再レンダリングと再マウントを防止できます。
まとめ
一人称視点で画面奥へ進む体験を React Router + react-three/fiber で実装しました。
主なポイント
- グリッドルームの構造: 床・天井・壁をグリッドとワイヤーフレームで表現
- カメラアニメーション: Z 軸方向に前進して奥へ進む感覚を演出
- レベル管理: React Router でページ遷移しながら複数の部屋を体験
- 再マウント対策: key props 削除 + useCallback でアニメーション中断を防止
