React Three Fiberでポートフォリオ的なサイトを作って悩んだことまとめ
React Three Fiber(以下、R3Fと記載)とは、Three.jsをなんかいい感じに書けるやつです。3DのWebサイトが作れます。
筆者は普段BlenderやUnityで3Dコンテンツ制作をしていますが、Three.jsはほとんど触ったことがありませんでした。このたびWebサイトを作る機会があり、3D表現ができないか考えていたところ、R3Fを知り、面白そうと思って挑戦してみました。
制作の中で、「こういう表現をしたい」と思っても、その方法にたどりつくのに時間がかかることが多々あったので、それらをまとめてみようと思います。
つくったもの
筆者の所属するサークル「百葉箱」のWebサイトをつくりました。
まだ手を加えたい箇所も多々あり、3Dモデルも簡素なものですが…、そこそこ動く状態になったので公開しています。
サークルで制作したものを3D空間上に配置し、クリックするとそれにフォーカスした上で、2Dで説明ダイアログを表示します。
Next.jsとFirebase Hostingを使っています。
筆者はR3FはおろかReactもほぼ初挑戦なので、そのあたりはなんとなくやっています。
なやんだこと
ローディング画面
「こういう表現をしたい」と思ってインターネットを探し回って、最終的に@react-three/dreiにたどり着く、ということが何度もありました。
その最たる例がLoaderです。Canvas要素の後ろに<Loader />
と1行書き足すだけで超カンタンにローディング画面を追加できます。ありがたすぎる。
Text/ImageでUIボタンを実装する
dreiのTextやImageを使うと、3D空間上にテキストや画像を配置することができます。
筆者の作例では、円形のUIボタンでTextとImageを使用しています。
マウスオーバーするとちょっとアニメーションしたりします。
function TextBox({children, width, offset, isHovered, caption, color, colorHovered, fontSize }) {
const hoveredWidth = width * 1.1;
const halo = useRef();
useFrame((state, delta) => {
easing.damp3(halo.current.scale, isHovered ? hoveredWidth : width, 0.2, delta)
easing.dampC(halo.current.material.color, isHovered ? colorHovered : color, 0.2, delta)
})
return (
<>
<Text
position={offset}
font="../../assets/MPLUS1p-Black.ttf"
fontSize={fontSize}
color={'white'}
>
{children}
<Text
position={[0,0.013,0]}
fontSize={fontSize/1.3}
>
{caption}
</Text>
<Image url="../../assets/white.png" ref={halo} position={[0,0,-0.001]} transparent opacity={0.96} radius={0.5} />
</Text>
</>
)
}
上記のようにTextとImageを組み合わせてコンポーネント化しています。
Text要素の中にText要素を入れ子にすることで、無理やりcaptionのオフセットを設定しています。他にもやりようがある気がする。
任意のタイミングでTextを改行する方法が最後まで分からず、こういう形になっています。
Imageで呼び出しているwhite.pngは2*2pxの白いテクスチャです。radius={0.5}にすることで、円形のボタンとしました。
hoverを表現するためのuseFrameの部分はどこかからサンプルを引っ張ってきたのですが、どこにあったか忘れた…。
上記のTextBoxコンポーネントを、3Dモデルのコンポーネントの中に仕込んでいます。
(以下、少し省略したコード)
function Post({props}) {
const [isHovered, setIsHovered] = useState(false);
useCursor(isHovered, /*'pointer', 'auto', document.body*/);
return (
<group {...props} dispose={null}
onPointerOver={() => {setIsHovered(true)}}
onPointerLeave={() => {setIsHovered(false)}}
>
<group>
<TextBox offset={[-0.01,0.095,-0.08]} width={0.05} isHovered={isHovered} caption={""} fontSize={0.007} color={"#3f3f46"} colorHovered={"#555"}>お問い合わせ</TextBox>
{/* <mesh /> */}
</group>
</group>
)
}
サラッと使いましたが、useCursorもdreiです。
インタラクト可能なものにマウスオーバーしたらカーソルを変更しています。
Billboardもdreiにあるので、組み合わせて使ってもよいかも。
アウトライン
dreiにOutlinesがあるのですが、これは使わず、背面法でアウトライン用のメッシュを作成しています。太さの微調整をしたいとか、特定箇所にアウトラインを出したくないといった都合です。
@react-three/postprocessing
にもOutlineがあって、こちらは描画範囲を囲むようなアウトラインを出してくれるのですが、透過まわりのトラブルが解決できなくて使いませんでした。
R3Fのdocsに載っていたサンプルがこちら。マウスオーバー時にこういうアウトライン出したかったので、再挑戦するかもしれません。
カメラ
マウスカーソルのX軸移動に応じて、カメラをヨーイングしています。
スマホでの体験はやや不安定です。悩みましたが、やはり動きがほしくて採用しました。
はじめはY軸移動もとっていたのですが、酔いやすいなと思ってやめました。
このサンプルを元に改造しています。
const Rig = ({ v = new Vector3() }) => {
return useFrame((state) => {
state.camera.position.lerp(v.set(-state.mouse.x / 20, 0.22, 0.5), 0.05);
})
};
オブジェクトをクリックした時には、そのオブジェクトズームするようにしています。
以下のようなコンポーネントを作りました。
function Controller ({children, pos, zoom, currentPos, currentZoom, setCurrentPos, setCurrentZoom}){
const lerp = (x, y, p) => {
return x + (y - x) * p;
}
useFrame((state) => {
let p = currentPos.lerp(pos,0.08);
state.camera.lookAt(p);
setCurrentPos(p);
let z = lerp(currentZoom, zoom, 0.05);
state.camera.zoom = z;
setCurrentZoom(z);
});
return(
<>
{children}
</>
)
}
上記のRigとControllerを、以下のようにCanvasの中で呼び出しています。
<Canvas>
<Controller pos={pos} zoom={zoom} currentPos={currentPos} currentZoom={currentZoom} setCurrentPos={setCurrentPos} setCurrentZoom={setCurrentZoom}>
{/* ここに表示するモデルデータとかを書く */}
</Controller>
<Rig/>
</Canvas>
なんでControllerは閉じタグも書く形にしてるんだったかは忘れました。
動画再生
モニターの部分から動画再生に関連する部分だけを抜き出すと、以下のようなコードになります。
autoPlay
と playsInline
をtrueにしておかないとスマホで動画が自動再生されません。
function Monitor({props}) {
const { nodes, materials } = useGLTF('../../assets/i_monitor.glb')
const [video] = useState(() => {
const vid = document.createElement("video");
vid.src = '../../assets/digest.mp4';
vid.loop = true;
vid.muted = true;
vid.autoPlay = true;
vid.playsInline = true;
vid.play();
return vid;
});
return (
<group {...props} dispose={null}>
<group position={[-0.07, 0, -0.19]}>
<mesh geometry={nodes.Cube.geometry} material={materials.M_Monitor} />
<mesh geometry={nodes.Plane.geometry} material={materials.Video}>
<meshStandardMaterial>
<videoTexture attach="map" args={[video]} />
</meshStandardMaterial>
</mesh>
</group>
</group>
)
}
onClickが貫通する
モニターをクリックするとモニターにカメラがフォーカスするようにしていますが、その後にモニター以外の場所をクリックするとカメラが初期位置に戻るようにしています。
初めはInteractableなオブジェクトの後ろ側の全体を覆うようにリセット用の判定を置いていたのですが、そうすると、どこをクリックしてもカメラがリセットされるようになりました。おそらく、レイキャストが交差した順に処理をしているので、一番後ろに置いた判定が最後に実行されてしまっていたようでした。
リセット用の判定をカメラのすぐ側に置くことで対応しました。しかし、これだとInteractableなオブジェクト同士が重なると困るので、何か他の対応を考えたほうがよい気がしています。
スマホのスクロール
スマホのブラウザのpull-to-reflesh的なものを無効化しておかないと、わざわざ画面がスクロールしてしまいます。
CSSでoverflow: hidden;
して対応しました。html要素とbody要素の両方に入れました。
筆者はNext.jsを使用していて、layout.jsのルーティングがよく分かっておらず苦戦したりしました。
負荷
初めはリアルタイムシャドウを入れていましたが、スマホが熱くなるのでやめました。PostProcessingも色々試したのですが、結局使いませんでした。
レスポンシブ対応
スマホ縦持ちは横幅が短いので、カメラの画角に映る範囲がPCと大きく異なります。そのため、配置はけっこう悩みました。
Aboutの導線とContactの導線はスマホで見切れる画面端に追いやり、フッターにそれらのボタンを追加することで対応しました。
制作途中、友人にスマホ表示で見てもらったりすると、どこをタップすればいいか分かってもらえなかったり。もともとは装飾物をいろいろ置きたいと思ってたのですが、撤去しました。
おわりに
色々と行き詰まることはあれど、やはり動きのある3Dコンテンツを作るのは楽しいですね。
Three.jsをほぼ触ったことなくても、まあなんとかなった。
また他にも何かつくりたいなーと思っています。
余談
サイトを作るにあたって、自宅にあるWiiのゲームを色々と観察していました。
特にすごいと思ったのが、マリオギャラクシーのギャラクシー選択画面でした。マリオギャラクシーのギャラクシー選択画面、めちゃくちゃいいですね…。
天文台からズームアウトして宇宙を見せる、ギャラクシーを選択するとそこにズームイン、マリオの入ったバブルは手前に…という一連の流れがとても美しかったです。ギャラクシー同士が重ならないように軌道上を進んでいたり、カメラ操作は結構自由度があったり、とにかくギャラクシー選択画面をひたすら眺めていました。
Wii Musicのメニュー画面や、似顔絵チャンネルでMiiを選択するときの挙動も観察したりしました。
WiiとかDSの時代にこういうUXを考えるの、めちゃ楽しそうだな…!という気持ちになりました。
Discussion