2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

react-three/fiberとReact Routerで実装する滑らかな3D空間遷移

Posted at

概要

一人称視点でのダンジョン徘徊系のゲームのように、前進するたびに奥へ奥へと進んでいく体験を React Router + react-three/fiber で実装しました。
遠近法で描かれたグリッドの部屋を通り抜けていく感覚を、ボタン押下によるページ遷移で表現します。

walk.gif

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>: 光源の影響を受けないシンプルなマテリアル(後述のambientLightpointLightを無視できる)。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 コールバックで親の状態が更新され、以下の理由で子コンポーネントが再マウントされていました

  1. key props による強制再マウント: <Component key={level} /> のように指定していると、同じ level でも再マウントされる
  2. コールバック関数の再生成: 毎回新しい関数参照が渡されると 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 でアニメーション中断を防止

参考サイト

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?