React Three Fiberで宙に浮かぶ3Dカードを作ってみよう



Three.jsをReactで扱いやすくしたライブラリ「React Three Fiber」を使って、3Dカードを作成する方法を紹介します。

全体のコードとデモ

https://codesandbox.io/p/sandbox/3d-card-forked-xsjwnz

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3945045/424db772-06df-680e-e915-9c3983a5c4c1.png)


## 使用するライブラリ

プロジェクト内で必要なライブラリをインストールします
```bash:terminal
npm install @react-three/fiber @react-three/drei three
# or 
yarn add @react-three/fiber @react-three/drei three
```

各ライブラリの大まかな役割は以下の通りです
### @react-three/fiber
Three.jsのReactレンダラーです。Three.jsをReactの宣言的な方法で扱うことができます。

### @react-three/drei
React Three Fiber用のサポートライブラリで、
よく使われるThree.jsの機能やコンポーネントを簡単に提供します

https://drei.docs.pmnd.rs/getting-started/introduction

### three
3D表現のコアライブラリです。テクスチャローディングなどで使用します。


## 実装手順

### ベースとなるCanvasの設定

まず、基本となるシーンの設定をします。親コンポーネントに以下のコードを記述します：

```jsx

function App() {
  return (
    <Canvas camera={{ position: [0, 1, 12], fov: 45 }}>
      <color attach="background" args={['#ffffff']} />
      <ambientLight intensity={1} />
      <directionalLight position={[0, 2, 5]} intensity={1.5} />
      <OrbitControls />
    </Canvas>
  )
}
```

各要素は以下の用途で使われています。

- `Canvas`: Three.jsのレンダリング領域を作成します
- `camera`: 3D空間を見る視点を設定します。(デフォルトでは中心の[0,0,0]をみる)
  - `position`: カメラの位置 [x, y, z]
  - `fov`: 視野角（Field of View）を度数で指定
- `color`: 背景色を設定
- `ambientLight`: 環境光（全体の明るさ）
- `directionalLight`: 太陽光のような指向性のある光源
- `OrbitControls`: マウスによる視点操作を可能にする

:::note info
デフォルトのThree.js(react-three-fiber)の座標系について：
カメラからみて
x軸: 左右方向（右が+、左が-）
y軸: 上下方向（上が+、下が-）
z軸: 前後方向（手前が+、奥が-）
:::

### カードの実装

useLoaderはテクスチャを読み込むことができます。この例ではペンギンの画像を読み込んでいます。
useFrameは1フレーム毎に動作するのでアニメーション処理を行うことができます。

return内の構造がよくわからない場合は
基本的に
`mesh` →入れ物
`geometry`　→オブジェクトの形
`material`（材質）→見た目(色や質感)
上記以外の構造のものはdreiからコンポーネントを呼び出しているか例外的なものという
理解で大丈夫な気がします。:v_tone1:


```jsx
const Card = () => {
  const ref = useRef()
  const texture = useLoader(THREE.TextureLoader, '/doubutu_penguin.png')

  const description = `水中の泳ぐスピードはとても速く
時速40kmに達することもある
これは捕食者から逃げるためや
エサを追いかけるために
発達した能力です
`

  useFrame((state) => {
    const time = state.clock.getElapsedTime()
    ref.current.rotation.y = Math.sin(time / 2) * 0.2
    ref.current.rotation.x = Math.cos(time / 2) * 0.1
  })

  return (
    <group ref={ref} position={[0, 1, 0]}>
      {/* カードの背景 - 枠（角丸） */}
      <RoundedBox position={[0, 0, -0.3]} args={[3.2, 5.2, 0.1]} radius={0.1} smoothness={4}>
        <meshStandardMaterial color="#2d3436" />
      </RoundedBox>

      {/* カードの背景 - メイン */}
      <mesh position={[0, 0, -0.15]}>
        <planeGeometry args={[3, 5]} />
        <meshStandardMaterial color="#34495e" />
      </mesh>

      {/* ヘッダー部分 */}
      <mesh position={[0, 2.1, -0.1]}>
        <planeGeometry args={[3, 0.5]} />
        <meshStandardMaterial color="#3498db" />
      </mesh>

      {/* タイトル */}
      <Text position={[0, 2.1, 0]} fontSize={0.275} color="white" anchorX="center" anchorY="middle" outlineWidth={0.02} outlineColor="#000">
        🐧ペンギン🐧
      </Text>

      {/* 画像部分 - 枠 */}
      <mesh position={[0, 0.5, -0.05]}>
        <planeGeometry args={[2.3, 2.3]} />
        <meshStandardMaterial color="yellow" />
      </mesh>

      {/* 画像部分 */}
      <mesh position={[0, 0.5, 0]}>
        <planeGeometry args={[2.2, 2.2]} />
        <meshStandardMaterial map={texture} />
      </mesh>

      {/* 説明文の背景 */}
      <mesh position={[0, -1.6, -0.05]}>
        <planeGeometry args={[2.8, 1.5]} />
        <meshStandardMaterial color="#2c3e50" />
      </mesh>

      {/* 説明文 */}
      <Text
        position={[0, -1.6, 0]}
        fontSize={0.15}
        color="white"
        maxWidth={2}
        textAlign="center"
        anchorX="center"
        anchorY="middle"
        lineHeight={1.5}
        whiteSpace="pre">
        {description}
      </Text>
    </group>
  )
}
```


#### groupコンポーネント

```jsx
<group ref={ref} position={[0, 1, 0]}>
```

- `group`: オブジェクトをグループ化して移動やアニメーションを可能とする
- `ref={ref}`: アニメーションのための参照を設定
- `position`: 位置指定


#### カードの外枠（RoundedBox）

```jsx
<RoundedBox 
  position={[0, 0, -0.3]} 
  args={[3.2, 5.2, 0.1]} 
  radius={0.1} 
  smoothness={4}
>
  <meshStandardMaterial color="#2d3436" />
</RoundedBox>
```

- `position`: 配置位置
- `args`: サイズ指定[幅, 高さ, 厚さ]
- `radius`: 角の丸み
- `smoothness`: 丸みの品質





#### メイン背景（mesh）

```jsx
<mesh position={[0, 0, -0.15]}>
  <planeGeometry args={[3, 5]} />
  <meshStandardMaterial color="#34495e" />
</mesh>
```

- `planeGeometry`: 平面の形状定義
- `args`: [幅, 高さ]で指定

#### タイトル（Text）

```jsx
<Text
  position={[0, 2.1, 0]}
  fontSize={0.275}
  color="white"
  anchorX="center"
  anchorY="middle"
  outlineWidth={0.02}
  outlineColor="#000"
>
  🐧ペンギン🐧
</Text>
```

- `position`: 最前面（z=0）
- `fontSize`: 文字サイズ
- `anchorX/Y`: テキストの基準点
- `outline`: 縁取りの設定

#### 画像エリア

```jsx
{/* 画像の枠 */}
<mesh position={[0, 0.5, -0.05]}>
  <planeGeometry args={[2.3, 2.3]} />
  <meshStandardMaterial color="yellow" />
</mesh>

{/* 画像本体 */}
<mesh position={[0, 0.5, 0]}>
  <planeGeometry args={[2.2, 2.2]} />
  <meshStandardMaterial map={texture} />
</mesh>
```

- 枠と画像を別々に作成
- `map={texture}`: テクスチャの適用（ここではペンギンの画像）

#### 説明文エリア

```jsx
<Text
  position={[0, -1.6, 0]}
  fontSize={0.15}
  color="white"
  maxWidth={2}
  textAlign="center"
  anchorX="center"
  anchorY="middle"
  lineHeight={1.5}
  whiteSpace="pre"
>
  {description}
</Text>
```

- `maxWidth`: テキストの最大幅
- `lineHeight`: 行間
- `whiteSpace="pre"`: 改行を保持



### 反射する地面の配置

```jsx
 ReflectiveGround = () => {
  return (
    <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -2, 0]}>
      <planeGeometry args={[15, 15]} />
      <MeshReflectorMaterial
        blur={[300, 100]}
        resolution={1024}
        mixBlur={0.8}
        mixStrength={0.5}
        roughness={0.3}
        depthScale={1.2}
        color="#fff"
        metalness={0.5}
      />
    </mesh>
  )
}
``` 

#### meshの設定
- `rotation={[-Math.PI / 2, 0, 0]}`: 平面を90度回転して床にする
- `position={[0, -2, 0]}`: カードの下に配置
- `planeGeometry args={[15, 15]}`: 15x15の平面を作成

#### MeshReflectorMaterialのパラメータ解説

```jsx
<MeshReflectorMaterial
  blur={[300, 100]}         // ぼかしの強さ [x, y]
  resolution={1024}         // 反射の解像度
  mixBlur={0.8}            // ぼかしの混ざり具合
  mixStrength={0.5}        // 反射の強さ
  roughness={0.3}          // 表面の粗さ（低いほど鏡面的）
  depthScale={1.2}         // 深度スケール
  color="#fff"             // 地面自体の色
  metalness={0.5}          // 金属的な質感の強さ
/>
```


:::note warn
パフォーマンスの注意点：
- `resolution`は高いほど品質が上がるが処理が重くなる
- `blur`値が大きいほど処理負荷が増加
:::

### 作成したカードと地面を追加

作成したコンポーネントを入れて完成です!
```diff_jsx
function App() {
  return (
    <Canvas camera={{ position: [0, 1, 12], fov: 45 }}>
      <color attach="background" args={['#ffffff']} />
      <ambientLight intensity={1} />
      <directionalLight position={[0, 2, 5]} intensity={1.5} />
     + <Card />
     + <ReflectiveGround />
      <OrbitControls />
    </Canvas>
  )
}
```

## まとめ
全体のコードは大体100行くらいのコードで3D表現ができました。
はじめてQiita記事を書いたので要領よく書けていない部分もありますが、
React Three Fiberを使うことで、Reactの書き方で手軽に3D表現が実装できそうと感じていただけたら嬉しいです。:relaxed: