【React Three Fiber】Reactを使ってポートフォリオを刷新した話

# 🔮 はじめに
React Three Fiberを使用して開発したポートフォリオの解説を掲載していきたいと思います．誰かの参考になれば幸いです．

# 🔮 自己紹介
初めまして．web開発を勉強している273*(ﾂﾅｻﾝﾄﾞ) / Kei.と申します．関西の大学生です．<br>Reactを勉強し始めて二ヶ月くらい経過しました．仕組みが少しずつ理解できるようになったので，私のポートフォリオを3D表現を用いて刷新してみることにしました．<br>
初めての投稿となりますので，文章や表現に誤りがある可能性がございますがご了承ください．
# 🔮 完成品
以下のリンクからポートフォリオを閲覧することができます．PC版の方はブラウザのハードウェアアクセラレーションをONにしてくださいね．

https://273doworks.com

https://github.com/273Do/NewMyPortfolio

テキストをクリックすることでデモを行なったりすることが可能です．

:::note warn
サイトと記事の画像が一部違いますが，技術的には同じです．
:::

# 🔮 制作について
非常に綺麗なシェーダーでテキストを表示していますが，これは公式のExamplesを参考にしました．

https://codesandbox.io/s/lxvqek

以下から実装までの手順を掲載していきます．
### 1. ライブラリのインストール
実装するにあたって必要なライブラリをインストールしていきます．

```Terminal
npm install @pmndrs/branding
npm install @react-three/drei
npm install @react-three/fiber
npm install @react-three/postprocessing
npm install @types/three
npm install leva
```
参考元と同様です．
### 2. 動作確認
とりあえず参考元のコードをコピペして自身の環境で動作することを確認します．
<details><summary>コード</summary>

```javascript
import { RGBELoader } from 'three-stdlib'
import { Canvas, useLoader } from '@react-three/fiber'
import {
  Center,
  Text3D,
  Instance,
  Instances,
  Environment,
  Lightformer,
  OrbitControls,
  RandomizedLight,
  AccumulativeShadows,
  MeshTransmissionMaterial
} from '@react-three/drei'
import { useControls, button } from 'leva'
import { EffectComposer, HueSaturation, BrightnessContrast } from '@react-three/postprocessing'

export function App() {
  const { autoRotate, text, shadow, ...config } = useControls({
    text: 'Inter',
    backside: true,
    backsideThickness: { value: 0.3, min: 0, max: 2 },
    samples: { value: 16, min: 1, max: 32, step: 1 },
    resolution: { value: 1024, min: 64, max: 2048, step: 64 },
    transmission: { value: 1, min: 0, max: 1 },
    clearcoat: { value: 0, min: 0.1, max: 1 },
    clearcoatRoughness: { value: 0.0, min: 0, max: 1 },
    thickness: { value: 0.3, min: 0, max: 5 },
    chromaticAberration: { value: 5, min: 0, max: 5 },
    anisotropy: { value: 0.3, min: 0, max: 1, step: 0.01 },
    roughness: { value: 0, min: 0, max: 1, step: 0.01 },
    distortion: { value: 0.5, min: 0, max: 4, step: 0.01 },
    distortionScale: { value: 0.1, min: 0.01, max: 1, step: 0.01 },
    temporalDistortion: { value: 0, min: 0, max: 1, step: 0.01 },
    ior: { value: 1.5, min: 0, max: 2, step: 0.01 },
    color: '#ff9cf5',
    gColor: '#ff7eb3',
    shadow: '#750d57',
    autoRotate: false,
    screenshot: button(() => {
      // Save the canvas as a *.png
      const link = document.createElement('a')
      link.setAttribute('download', 'canvas.png')
      link.setAttribute('href', document.querySelector('canvas').toDataURL('image/png').replace('image/png', 'image/octet-stream'))
      link.click()
    })
  })
  return (
    <Canvas shadows orthographic camera={{ position: [10, 20, 20], zoom: 80 }} gl={{ preserveDrawingBuffer: true }}>
      <color attach="background" args={['#f2f2f5']} />
      {/** The text and the grid */}
      <Text config={config} rotation={[-Math.PI / 2, 0, 0]} position={[0, -1, 2.25]}>
        {text}
      </Text>
      {/** Controls */}
      <OrbitControls
        autoRotate={autoRotate}
        autoRotateSpeed={-0.1}
        zoomSpeed={0.25}
        minZoom={40}
        maxZoom={140}
        enablePan={false}
        dampingFactor={0.05}
        minPolarAngle={Math.PI / 3}
        maxPolarAngle={Math.PI / 3}
      />
      {/** The environment is just a bunch of shapes emitting light. This is needed for the clear-coat */}
      <Environment resolution={32}>
        <group rotation={[-Math.PI / 4, -0.3, 0]}>
          <Lightformer intensity={20} rotation-x={Math.PI / 2} position={[0, 5, -9]} scale={[10, 10, 1]} />
          <Lightformer intensity={2} rotation-y={Math.PI / 2} position={[-5, 1, -1]} scale={[10, 2, 1]} />
          <Lightformer intensity={2} rotation-y={Math.PI / 2} position={[-5, -1, -1]} scale={[10, 2, 1]} />
          <Lightformer intensity={2} rotation-y={-Math.PI / 2} position={[10, 1, 0]} scale={[20, 2, 1]} />
          <Lightformer type="ring" intensity={2} rotation-y={Math.PI / 2} position={[-0.1, -1, -5]} scale={10} />
        </group>
      </Environment>
      {/** Soft shadows */}
      <AccumulativeShadows frames={100} color={shadow} colorBlend={5} toneMapped={true} alphaTest={0.9} opacity={1} scale={30} position={[0, -1.01, 0]}>
        <RandomizedLight amount={4} radius={10} ambient={0.5} intensity={1} position={[0, 10, -10]} size={15} mapSize={1024} bias={0.0001} />
      </AccumulativeShadows>
    </Canvas>
  )
}

const Grid = ({ number = 23, lineWidth = 0.026, height = 0.5 }) => (
  // Renders a grid and crosses as instances
  <Instances position={[0, -1.02, 0]}>
    <planeGeometry args={[lineWidth, height]} />
    <meshBasicMaterial color="#999" />
    {Array.from({ length: number }, (_, y) =>
      Array.from({ length: number }, (_, x) => (
        <group key={x + ':' + y} position={[x * 2 - Math.floor(number / 2) * 2, -0.01, y * 2 - Math.floor(number / 2) * 2]}>
          <Instance rotation={[-Math.PI / 2, 0, 0]} />
          <Instance rotation={[-Math.PI / 2, 0, Math.PI / 2]} />
        </group>
      ))
    )}
    <gridHelper args={[100, 100, '#bbb', '#bbb']} position={[0, -0.01, 0]} />
  </Instances>
)

function Text({ children, config, font = '/Inter_Medium_Regular.json', ...props }) {
  const texture = useLoader(RGBELoader, 'https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/1k/aerodynamics_workshop_1k.hdr')
  return (
    <>
      <group>
        <Center scale={[0.8, 1, 1]} front top {...props}>
          <Text3D
            castShadow
            bevelEnabled
            font={font}
            scale={5}
            letterSpacing={-0.03}
            height={0.25}
            bevelSize={0.01}
            bevelSegments={10}
            curveSegments={128}
            bevelThickness={0.01}>
            {children}
            <MeshTransmissionMaterial {...config} background={texture} />
          </Text3D>
        </Center>
        <Grid />
      </group>
    </>
  )
}

```
</details>

### 3. 書き換え
#### 背景色や文字をツールごとに変更させる
動作が確認できたらコードを書き換えていきます．<br>
完成ページを見ていただけると，ツールアイコンを選択した時やテーマを切り替えるたびに文字や色，背景色が変わっていると思います．参考元のコードでは下記のようにプロパティが設定されており，こちらを変更することによってマテリアルの編集が可能です．
<details><summary>コード</summary>

```react
export function App() {
  const { autoRotate, text, shadow, ...config } = useControls({
    text: 'Inter',
    backside: true,
    backsideThickness: { value: 0.3, min: 0, max: 2 },
    samples: { value: 16, min: 1, max: 32, step: 1 },
    resolution: { value: 1024, min: 64, max: 2048, step: 64 },
    transmission: { value: 1, min: 0, max: 1 },
    clearcoat: { value: 0, min: 0.1, max: 1 },
    clearcoatRoughness: { value: 0.0, min: 0, max: 1 },
    thickness: { value: 0.3, min: 0, max: 5 },
    chromaticAberration: { value: 5, min: 0, max: 5 },
    anisotropy: { value: 0.3, min: 0, max: 1, step: 0.01 },
    roughness: { value: 0, min: 0, max: 1, step: 0.01 },
    distortion: { value: 0.5, min: 0, max: 4, step: 0.01 },
    distortionScale: { value: 0.1, min: 0.01, max: 1, step: 0.01 },
    temporalDistortion: { value: 0, min: 0, max: 1, step: 0.01 },
    ior: { value: 1.5, min: 0, max: 2, step: 0.01 },
    color: '#ff9cf5',
    gColor: '#ff7eb3',
    shadow: '#750d57',
    autoRotate: false,
    screenshot: button(() => {
      // Save the canvas as a *.png
      const link = document.createElement('a')
      link.setAttribute('download', 'canvas.png')
      link.setAttribute('href', document.querySelector('canvas').toDataURL('image/png').replace('image/png', 'image/octet-stream'))
      link.click()
    })
  })
```
</details>

直接プロパティを変数で変更するようにしたら画面上でマテリアル変更されるだろうと考え，変数を書き換えるボタンを設置し試したところ全く変化ありませんでした．当初困惑してましたが，以下のようにプロパティの値をuseEffectを用いて更新してあげる必要がありました．併せて必要な変数も関数に追加していきます．
```react
export function Three({ mainText, mainColor, mainFunc, imgURL, BGtheme }) {
  
  const { autoRotate, text, shadow, ...config } = useControls({
    //省略
  });

  useEffect(() => {
      config.text = mainText;
      config.color = mainColor[0];
      config.gColor = mainColor[1];
      config.shadow = mainColor[2];
      BGColor = BGtheme;
   }, [mainText, mainColor, BGColor]);

  return(
    //省略
  );
}
```
背景色や文字に関しては以下のように直接入れてあげても動作します．
```react
//変更前
//背景：50行目
 <color attach="background" args={['#f2f2f5']} />
```
```react
//変更後
//背景18行目下に追加
var BGColor = BGtheme ? "#222" : "#f4ede4";
//背景：50行目
<color attach="background" args={[BGColor]} />
```
```react
//変更前
//文字：20行目
text: 'Inter',
//文字70行目
{text}
```
```react
//変更後
//文字：20行目
text: mainText,
//文字70行目
{mainText}
```
***
#### 文字にクリック判定を設定する
こちらはreturnの中の __＜Text3D＞__ に __onClick__ を追記することでクリック判定を設定できます．
```react
 <Text3D
    castShadow
    bevelEnabled
    font={font}
    scale={5}
    letterSpacing={-0.03}
    lineHeight={0.6}
    height={0.25}
    bevelSize={0.01}
    bevelSegments={10}
    curveSegments={128}
    bevelThickness={0.01}
    onClick={() => demo(func, img)} //<-コレ
   >
```
私はデモ画面を表示するようにしています．これで文字の部分をクリックすることで任意の関数を実行することが可能になります．
### 4. コンポーネントの設定
上記で変更を加えたコンポーネントを使うための記述をしていきます．私は __Three__ というコンポーネントとして記述しました．変数は __mainText, mainColor, mainFunc, imgURL, BGtheme__ です．役割は以下の通りです．
| 変数名 | 役割 
:------------:|:------------
| mainText    | 立体の文字を設定する文字列        
| mainColor   | 立体文字用の3つのカラーコードが格納されている配列
| mainFunc    | ツールを識別してデモ画面を表示するためのid       
| imgURL     | ツールロゴのパス         
| BGtheme     | ライトテーマとダークテーマの状態が格納されている

ツールページコンポーネント(Tool)でThreeコンポーネントを
```react
 <Three
   cmainText={text}
   mainColor={color}
   mainFunc={func}
   imgURL={img}
   BGtheme={theme}
 />
```
といったように呼び出しており，App.jsxの方で他の変数と一緒に以下のように呼び出しています．
```react

 <Tool
   text={"Credit_\nChecker"}
   desc={
   "ユニパからダウンロードした 成績表 PDFファイルを読み込むと，自動で不足単位数や修得進捗，GPA表示してくれます．現在は情報と経済学部に対応しています．"}
   PTime={"2週間"}
   color={["#79a4d3", "#4e6292", "#0a2d53"]}
   func={0}
   img={Cc}
 />
          
 <Tool
   text={"My_\nPortfolio"}
   desc={"私のポートフォリオを作成しました．"}
   PTime={"2週間"}
   color={["#d5b6f8", "#b8cce8", "#3d1b65"]}
   func={5}
   img={Mp}
 />
             
```
__text，color，func，img__ が該当する部分です．この辺りは配列データにしてmap関数などで呼び出したりするとより綺麗に記述できるかと思います．今回はやりませんでしたが．
### 5. 動作確認
#### 動いた！
これでToolページの完成です．新たなツールができても先述のようにApp.jsxに追記するだけでOK．楽チンだね．
![スクリーンショット 2023-08-11 23.05.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3530539/370ad6d9-6368-2cc3-d4d0-877ca9cb982e.png)
![スクリーンショット 2023-08-06 23.53.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3530539/daac50c4-1ac2-0a47-8db2-e792e2d67aab.png)
![スクリーンショット 2023-08-06 23.54.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3530539/b6385ecc-41d0-2b64-eb05-d81bf117428c.png)

# 🔮 コード全体
ポートフォリオ自体のソースはGitHubにて公開しておりますが，念の為今回解説したコードを下記に掲載しておきます．
<details><summary>コード</summary>

```react
import { RGBELoader } from "three-stdlib";
import { Canvas, useLoader } from "@react-three/fiber";
import {
  Center,
  Text3D,
  Instance,
  Instances,
  Environment,
  Lightformer,
  OrbitControls,
  RandomizedLight,
  AccumulativeShadows,
  MeshTransmissionMaterial,
} from "@react-three/drei";
import { useControls, button } from "leva";
import { useEffect, useState } from "react";
import { demo } from "./components/Preview";

export function Three({ mainText, mainColor, mainFunc, imgURL, BGtheme }) {
  var BGColor = BGtheme ? "#222" : "#f4ede4";

  const { autoRotate, text, shadow, ...config } = useControls({
    text: mainText,
    backside: true,
    backsideThickness: { value: 0.3, min: 0, max: 2 },
    samples: { value: 16, min: 1, max: 32, step: 1 },
    resolution: { value: 512, min: 64, max: 2048, step: 64 },
    transmission: { value: 1, min: 0, max: 1 },
    clearcoat: { value: 0, min: 0.1, max: 1 },
    clearcoatRoughness: { value: 0.0, min: 0, max: 1 },
    thickness: { value: 0.3, min: 0, max: 5 },
    chromaticAberration: { value: 5, min: 0, max: 5 },
    anisotropy: { value: 0.3, min: 0, max: 1, step: 0.01 },
    roughness: { value: 0, min: 0, max: 1, step: 0.01 },
    distortion: { value: 0.5, min: 0, max: 4, step: 0.01 },
    distortionScale: { value: 0.1, min: 0.01, max: 1, step: 0.01 },
    temporalDistortion: { value: 0.08, min: 0, max: 1, step: 0.01 },
    ior: { value: 1.5, min: 0, max: 2, step: 0.01 },
    color: mainColor[0],
    gColor: mainColor[1],
    shadow: mainColor[2],
    autoRotate: true,
  });

  // mainTextやmainColorが変更されたときにconfigの値を更新する
  useEffect(() => {
    config.text = mainText;
    config.color = mainColor[0];
    config.gColor = mainColor[1];
    config.shadow = mainColor[2];
    BGColor = BGtheme;
  }, [mainText, mainColor, BGColor]);

  return (
    <Canvas
      shadows
      orthographic
      camera={{ position: [10, 20, 20], zoom: 80 }}
      gl={{ preserveDrawingBuffer: true }}
    >
      <color attach="background" args={[BGColor]} />
      {/** The text and the grid */}
      <Text
        func={mainFunc}
        img={imgURL}
        config={config}
        rotation={[-Math.PI / 2, 0, 0]}
        position={[0, -1, 5]}
      >
        {mainText}
      </Text>
      {/** Controls */}
      <OrbitControls
        autoRotate={autoRotate}
        autoRotateSpeed={-0.3}
        zoomSpeed={0.25}
        minZoom={40}
        maxZoom={140}
        enablePan={false}
        dampingFactor={0.05}
        minPolarAngle={Math.PI / 3}
        maxPolarAngle={Math.PI / 3}
      />
      {/** The environment is just a bunch of shapes emitting light. This is needed for the clear-coat */}
      <Environment resolution={32}>
        <group rotation={[-Math.PI / 4, -0.3, 0]}>
          <Lightformer
            intensity={20}
            rotation-x={Math.PI / 2}
            position={[0, 5, -9]}
            scale={[10, 10, 1]}
          />
          <Lightformer
            intensity={2}
            rotation-y={Math.PI / 2}
            position={[-5, 1, -1]}
            scale={[10, 2, 1]}
          />
          <Lightformer
            intensity={2}
            rotation-y={Math.PI / 2}
            position={[-5, -1, -1]}
            scale={[10, 2, 1]}
          />
          <Lightformer
            intensity={2}
            rotation-y={-Math.PI / 2}
            position={[10, 1, 0]}
            scale={[20, 2, 1]}
          />
          <Lightformer
            type="ring"
            intensity={2}
            rotation-y={Math.PI / 2}
            position={[-0.1, -1, -5]}
            scale={10}
          />
        </group>
      </Environment>
      {/** Soft shadows */}
      <AccumulativeShadows
        frames={100}
        color={mainColor[2]}
        colorBlend={5}
        toneMapped={true}
        alphaTest={0.9}
        opacity={1}
        scale={30}
        position={[0, -1.01, 0]}
      >
        <RandomizedLight
          amount={4}
          radius={10}
          ambient={0.5}
          intensity={1}
          position={[0, 10, -10]}
          size={15}
          mapSize={1024}
          bias={0.0001}
        />
      </AccumulativeShadows>
    </Canvas>
  );
}

const Grid = ({ number = 23, lineWidth = 0.026, height = 0.5 }) => (
  // Renders a grid and crosses as instances
  <Instances position={[0, -1.02, 0]}>
    <planeGeometry args={[lineWidth, height]} />
    <meshBasicMaterial color="#666" />
    {Array.from({ length: number }, (_, y) =>
      Array.from({ length: number }, (_, x) => (
        <group
          key={x + ":" + y}
          position={[
            x * 2 - Math.floor(number / 2) * 2,
            -0.01,
            y * 2 - Math.floor(number / 2) * 2,
          ]}
        >
          <Instance rotation={[-Math.PI / 2, 0, 0]} />
          <Instance rotation={[-Math.PI / 2, 0, Math.PI / 2]} />
        </group>
      ))
    )}
    {/* <gridHelper args={[100, 100, "#bbb", "#bbb"]} position={[0, -0.01, 0]} /> */}
  </Instances>
);

function Text({
  func,
  img,
  children,
  config,
  font = "/Inter_Medium_Regular.json",
  ...props
}) {
  const texture = useLoader(
    RGBELoader,
    "https://dl.polyhaven.org/file/ph-assets/HDRIs/hdr/1k/aerodynamics_workshop_1k.hdr"
  );

  return (
    <>
      <group>
        <Center scale={[0.8, 1, 1]} front top {...props}>
          <Text3D
            castShadow
            bevelEnabled
            font={font}
            scale={5}
            letterSpacing={-0.03}
            lineHeight={0.6}
            height={0.25}
            bevelSize={0.01}
            bevelSegments={10}
            curveSegments={128}
            bevelThickness={0.01}
            onClick={() => demo(func, img)}
          >
            {children}
            <MeshTransmissionMaterial {...config} background={texture} />
          </Text3D>
        </Center>
        <Grid />
      </group>
    </>
  );
}

```
</details>

# 🔮 使用言語，ライブラリ，フレームワーク
ポートフォリオ開発で使用したものです．

OS：macOS Ventura
デザイン：Figma，Blender
開発ツール：Visual Studio Code，Git，GitHub，Docker，~~FileZila~~ AWS
サイト系：HTML，CSS，JavaScript，React，react-router-dom
3D：three.js，pmndrs/branding，react-three(drei，fiber，postprocessing)，leva
ポップアップ：Sweetalert2
アイコン：DEVICON，ionicons

# 🔮 最後に
いかがでしたでしょうか．他のページはよくあるReactを用いたwebページと同じです．自力で React Three Fiber を用いて制作を行うのはかなりの知識と労力がかかると思います．マスターしたいところですが私には少し早かったかもしれません(笑)．公式のExamplesには更に面白い例が沢山掲載されているので覗くだけでも楽しいですよ．長文となりましたがここまで読んでいただきありがとうございました．

https://docs.pmnd.rs/react-three-fiber/getting-started/examples