🔮 はじめに
React Three Fiberを使用して開発したポートフォリオの解説を掲載していきたいと思います.誰かの参考になれば幸いです.
🔮 自己紹介
初めまして.web開発を勉強している273*(ツナサンド) / Kei.と申します.関西の大学生です.
Reactを勉強し始めて二ヶ月くらい経過しました.仕組みが少しずつ理解できるようになったので,私のポートフォリオを3D表現を用いて刷新してみることにしました.
初めての投稿となりますので,文章や表現に誤りがある可能性がございますがご了承ください.
🔮 完成品
以下のリンクからポートフォリオを閲覧することができます.PC版の方はブラウザのハードウェアアクセラレーションをONにしてくださいね.
テキストをクリックすることでデモを行なったりすることが可能です.
サイトと記事の画像が一部違いますが,技術的には同じです.
🔮 制作について
非常に綺麗なシェーダーでテキストを表示していますが,これは公式のExamplesを参考にしました.
以下から実装までの手順を掲載していきます.
1. ライブラリのインストール
実装するにあたって必要なライブラリをインストールしていきます.
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. 動作確認
とりあえず参考元のコードをコピペして自身の環境で動作することを確認します.
コード
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>
</>
)
}
3. 書き換え
背景色や文字をツールごとに変更させる
動作が確認できたらコードを書き換えていきます.
完成ページを見ていただけると,ツールアイコンを選択した時やテーマを切り替えるたびに文字や色,背景色が変わっていると思います.参考元のコードでは下記のようにプロパティが設定されており,こちらを変更することによってマテリアルの編集が可能です.
コード
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()
})
})
直接プロパティを変数で変更するようにしたら画面上でマテリアル変更されるだろうと考え,変数を書き換えるボタンを設置し試したところ全く変化ありませんでした.当初困惑してましたが,以下のようにプロパティの値をuseEffectを用いて更新してあげる必要がありました.併せて必要な変数も関数に追加していきます.
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(
//省略
);
}
背景色や文字に関しては以下のように直接入れてあげても動作します.
//変更前
//背景:50行目
<color attach="background" args={['#f2f2f5']} />
//変更後
//背景18行目下に追加
var BGColor = BGtheme ? "#222" : "#f4ede4";
//背景:50行目
<color attach="background" args={[BGColor]} />
//変更前
//文字:20行目
text: 'Inter',
//文字70行目
{text}
//変更後
//文字:20行目
text: mainText,
//文字70行目
{mainText}
文字にクリック判定を設定する
こちらはreturnの中の <Text3D> に onClick を追記することでクリック判定を設定できます.
<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コンポーネントを
<Three
cmainText={text}
mainColor={color}
mainFunc={func}
imgURL={img}
BGtheme={theme}
/>
といったように呼び出しており,App.jsxの方で他の変数と一緒に以下のように呼び出しています.
<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.楽チンだね.
🔮 コード全体
ポートフォリオ自体のソースはGitHubにて公開しておりますが,念の為今回解説したコードを下記に掲載しておきます.
コード
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>
</>
);
}
🔮 使用言語,ライブラリ,フレームワーク
ポートフォリオ開発で使用したものです.
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には更に面白い例が沢山掲載されているので覗くだけでも楽しいですよ.長文となりましたがここまで読んでいただきありがとうございました.