React Three Fiber + TypeScript: 3次元空間で立方体を回してみる

[React Three Fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction)は、Reactのコンポーネントで[three.js](https://threejs.org/)の3次元空間を組み立てて、3Dオブジェクトが操作できるライブラリです。Reactの宣言的な記述で、WebGLによる3D表現が簡単にできます。採り上げるのは、3次元空間で立方体を回してみるというつぎのような作例です。カメラはマウスでインタラクティブに操作できます。

**本稿の作例**
https://codesandbox.io/s/react-three-fiber-typescript-basic-example-dnzllu?file=/src/App.tsx:579-605

**マウスによるカメラ操作**
* ドラッグ: 立方体の中心から一定の距離で旋回する。
* 右ボタンドラッグ: カメラの方向は保ったまま、フレーミングを水平・垂直に移動させる。
* ホイール: カメラの方向に、映像を拡大・縮小させる。

# ひな形プロジェクトの作成とインストール
まず、作業の準備です。プロジェクトのひな形は、[Create React App](https://ja.reactjs.org/docs/create-a-new-react-app.html)でつくります(プロジェクト名は`react-three-fiber-typescript`としました)。テンプレート(`--template`)のオプションに加えたのはTypeScriptです。もっとも、型推論が働くので、明示的な型づけはほとんどしていません。

```terminal
npx create-react-app react-three-fiber-typescript --template typescript
```

そして、three.js(`three`)とReact Three Fiber(`@react-three/fiber`)のインストールです。`@types/three`も加えてください。

```terminal
npm install three @react-three/fiber
npm install --save-dev @types/three
```


# 3次元空間に立方体を加える
素の(標準)JavaScriptでthree.jsによる3次元空間の舞台を整えるには、まずつぎの3つのオブジェクトがつくられなければなりません。

* シーン(scene)
  - 3次元空間を表し、3Dオブジェクトやライトが加えられる。
* カメラ(camera)
  - 3次元空間を映し、レンダラーに送って画面に投影する。
* レンダラー(renderer)
  - カメラから受け取った画像を、画面の再描画のたびに更新する。

それが、React Three Fiberでは、[`Canvas`](https://docs.pmnd.rs/react-three-fiber/api/canvas)コンポーネントひとつで済むのです。作例のつぎのコード(`src/App.tsx`)には、`camera`プロパティでカメラの定めを加えました。けれど、デフォルトの設定はされているので、除いても3次元空間の準備はできているのです。`Canvas`の子として加える3Dオブジェクトの立方体(`Cube`)は、このあとコンポーネントとして定めます。

```react:src/App.tsx
import React from 'react';
import { Canvas } from '@react-three/fiber';
import { Cube } from './Cube';

function App() {
	return (
		<div className="App">
			<Canvas
				camera={{
					fov: 45,
					near: 0.1,
					far: 1000,
					position: [0, 0, 5]
				}}
			>
				<Cube />
			</Canvas>
		</div>
	);
}

export default App;
```

`camera`プロパティに定めた項目の内容はつぎのとおりです。こうした設定をするには、three.jsのドキュメントに当たらなければなりません。標準JavaScriptで使うthree.jsの基本については、「[three.js + TypeScript: 3次元空間で立方体を回してみる](https://qiita.com/FumioNonaka/items/dab4b854a1e3b541594c)」をご参照ください。

* `fov`: 視野(field of view)またはカメラの用語で[画角](https://ja.wikipedia.org/wiki/%E7%94%BB%E8%A7%92)。写される光景の範囲を角度(度数)で示す。数字が大きいと広角で、写る範囲は広がるかわり、対象物は相対的に小さくなる。小さい角度は望遠に近づき、対象物が相対的に大きく写る。
* `aspect`: [アスペクト比](https://ja.wikipedia.org/wiki/%E3%82%A2%E3%82%B9%E3%83%9A%E3%82%AF%E3%83%88%E6%AF%94)で、描画する矩形領域の $ \frac{幅}{高さ} \$ 。矩形領域の比と合わなければ、画像の水平・垂直比が歪む。
* `near`: オブジェクトを描画するもっとも近い距離。これより近くのオブジェクトは画面に描かれない。
* `far`: オブジェクトを描画するもっとも遠い距離。これより遠くのオブジェクトは画面に描かれない。

3Dオブジェクトを定めるのは`mesh`コンポーネントです。その中には形状を決めるジオメトリと、表面素材のマテリアルが加えられなければなりません。立方体は`boxBufferGeometry`でつくれます。マテリアルに選んだのは`meshPhongMaterial`です。3Dオブジェクトはデフォルトでは、3次元空間の原点(0, 0, 0)を中心に配置されます。

つぎのコード例で`boxBufferGeometry`には、プロパティとして`args`に配列を与えました。これは、three.jsの`BoxBufferGeometry()`コンストラクタに渡す引数(arguments)の意味です。立方体の幅と高さ、および奥行きを定めます。なお、three.jsでは、距離や長さは1を基本とした数値です。カメラの設定でオブジェクトは大きくも小さくも描かれるので、単位は気にせず比率と捉えて構いません。

マテリアルに用いたのは、three.jsの[`MeshPhongMaterial`](https://threejs.org/docs/#api/en/materials/MeshPhongMaterial)です。表面は鏡面反射する光沢があります。`color`プロパティには、CSSと同じかたちでカラーが定められ、16進数の場合は数値でも構いません。

```react:src/Cube.tsx
import { FC } from 'react';

export const Cube: FC = () => {
	return (
		<mesh>
			<boxBufferGeometry args={[1, 1, 1]} />
			<meshPhongMaterial color="aqua" />
		</mesh>
	);
};
```

アプリケーションを実行してみると、立方体が真っ黒に描かれるでしょう。明かりがないからです。デフォルトの背景色が白であることに、ごまかされてはいけません。3次元空間に光を与えないと、3Dオブジェクトの正しい姿は見えないのです。


# 平行光源を加える
まず光源として加えるのは、`directionalLight`([`DirectionalLight`](https://threejs.org/docs/?q=directionalLight#api/en/lights/DirectionalLight))です。「[平行光源](https://wgld.org/d/webgl/w021.html)」とも呼ばれ、太陽光のようにひとつの方向から、光が平行に差し込みます。光が差してくる方向を定めるのが`position`プロパティです。原点に向かって差し込むので、座標(1, 1, 1)は、原点(0, 0, 0)と立方体の初期位置の前面右上角の座標(0.5, 0.5, 0.5)を結んだ直線の方向になります(右斜め上手前)。`intensity`プロパティの数値は、1を100%とした光の明るさです。


```react:src/App.tsx
function App() {
	return (
		<div className="App">
			<Canvas

			>

				<directionalLight position={[1, 1, 1]} intensity={0.8} />
			</Canvas>
		</div>
	);
}
```

これで、指定したカラー(`aqua`)の3Dオブジェクトが表示されたでしょう。もっとも、カメラに正面向きなので、立方体か正方形平面か区別がつきません。


# 立方体に回転のアニメーションを加える
立方体をアニメーションで回転してみましょう。そのとき用いるフックが、React Three Fiberの[`useFrame`](https://docs.pmnd.rs/react-three-fiber/api/hooks#useframe)です。立方体の`mesh`コンポーネントには[`ref`](https://ja.reactjs.org/docs/refs-and-the-dom.html#gatsby-focus-wrapper)プロパティを与えておきます。`x`および`y`軸周りの回転を加えるのは`rotation`プロパティです(単位ラジアン)。フックに定めたコールバックは、再描画のたびに呼び出されます。立方体が回っていることを確かめられるでしょう。

```react:
import { useFrame } from '@react-three/fiber';
// import { FC } from 'react';
import { FC, useRef } from 'react';
import type { Mesh } from 'three';

export const Cube: FC = () => {
	const cubeRef = useRef<Mesh>(null);
	useFrame(() => {
		const cube = cubeRef.current;
		if (!cube) return;
		cube.rotation.x += 0.01;
		cube.rotation.y += 0.01;
	});
	return (
		// <mesh>
		<mesh ref={cubeRef}>

		</mesh>
	);
};
```


# 環境光を加える
まだ、少し気になるかもしれません。光の当たっていない面が漆黒であることです。物理的に考えれば、仕方ありません。でも、現実と照らし合わせると違和感があるでしょう。そこで用いる光源がコンポーネント`ambientLight`です。`args`プロパティの配列は、[`AmbientLight()`](https://threejs.org/docs/?q=ambientLight#api/en/lights/AmbientLight)コンストラクタの引数で、光のカラーを定めます。明るさの`intensity`プロパティの値は控えめにしました。

```react:src/App.tsx
function App() {
	return (
		<div className="App">
			<Canvas

			>

				<ambientLight args={[0xffffff]} intensity={0.2} />

			</Canvas>
		</div>
	);
}
```

立方体の陰になった面にも明るさが加わったでしょう。


# `OrbitControls`でカメラをマウス制御する
[`OrbitControls`](https://threejs.org/docs/#examples/en/controls/OrbitControls)を用いると、カメラが簡単にマウスで制御できます。`@react-three/fiber`とは別モジュールとして、インストールに加えてください。

```terminal
npm install @react-three/drei
```

使い方は簡単で、`Canvas`に`OrbitControls`コンポーネントを加えるだけです。マウス制御の基本機能は、すでにデフォルトで備わっています。

```react:src/App.tsx
import { OrbitControls } from '@react-three/drei';

function App() {
	return (
		<div className="App">
			<Canvas

			>

				<OrbitControls />
			</Canvas>
		</div>
	);
}
```

これで、冒頭にご紹介した作例はでき上がりです。ふたつのモジュールの記述全体を、つぎのコード001にまとめました。JSXを除いたJavaScriptコードはほとんどないのが驚きです。

#### コード001■3次元空間の立方体を回転させてカメラはマウス制御する
```react:src/App.tsx
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import { Cube } from './Cube';

function App() {
	return (
		<div className="App">
			<Canvas
				camera={{
					fov: 45,
					near: 0.1,
					far: 1000,
					position: [0, 0, 5]
				}}
			>
				<Cube />
				<ambientLight args={[0xffffff]} intensity={0.2} />
				<directionalLight position={[1, 1, 1]} intensity={0.8} />
				<OrbitControls />
			</Canvas>
		</div>
	);
}

export default App;
```

```react:src/Cube.tsx
import { useFrame } from '@react-three/fiber';
import { FC, useRef } from 'react';
import type { Mesh } from 'three';

export const Cube: FC = () => {
	const cubeRef = useRef<Mesh>(null);
	useFrame(() => {
		const cube = cubeRef.current;
		if (!cube) return;
		cube.rotation.x += 0.01;
		cube.rotation.y += 0.01;
	});
	return (
		<mesh ref={cubeRef}>
			<boxBufferGeometry args={[1, 1, 1]} />
			<meshPhongMaterial color="aqua" />
		</mesh>
	);
};
```