【React】react-three-fibarで3D表現をする（Mixamoを使ったアニメーションモデル）

<!-- ========================================================== -->
# 概要
<!-- ========================================================== -->
Three.jsのReact用ライブラリ **react-three-fibar** を使用して、Adobe Mixamoからダウンロードしたモデルをブラウザ上で表示する方法をまとめました。

https://nemutas-mixamo-animation.web.app/
![output(video-cutter-js.com).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/3dc9c4d2-11d5-544a-5c9f-7b7bcd47146c.gif)

着想は公式デモから得ていますが、少し作りを変えています。

https://drei.pmnd.rs/?path=/story/abstractions-useanimations--use-animations-st

デモでは、ひとつのモデルファイルに3Dモデルと複数のアニメーションをまとめて、それをインポートしています。
ただこの方法だと、MixamoからダウンロードしたデータをBlenderなりで統合する手間がかかります。この手間は、アニメーションを後から追加したり3Dモデルを変更する場合も発生します。

そこで、実装におけるコンセプトとして、

:::note info
3Dモデルとアニメーションのファイルをひとつひとつ分けて扱い、導入コストが低く、拡張性が高い設計
:::
にします。

<!-- ========================================================== -->
# 環境
<!-- ========================================================== -->
* react － 17.0.2
* typescript － 4.4.3
* three － 0.132.2
* react-three/fiber － 7.0.7
* react-three/drei － 7.8.2
* valtio － 1.2.3
* mui/material － 5.0.0
* mui/icons-material － 5.0.0

<!-- ========================================================== -->
# モデルファイル（Adobe Mixamo）
<!-- ========================================================== -->
Mixamoでは、3Dモデルやアニメーションを（.fbx）形式で、無料でダウンロードすることができます。
Adobeへのユーザー登録は必要です。

https://www.mixamo.com

実装では、3Dモデルとして**YBot**、アニメーションとして**Walking**や**Swing Dancing**などを使用させて頂きました。

### ダウンロード

* モデルファイルはをダウンロードする場合は、以下の設定にします。
![無題.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/9dce523f-a55d-30a3-6ea7-9a49cb478258.png)

* アニメーションファイルをダウンロードする場合は、以下の設定にします。
特に、3Dモデル自体のSkinはいらないので、**Without Skin**にします。
![スクリーンショット 2021-09-22 134143.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/30af5f84-8da8-3520-861b-889ccd4877e0.png)

### .glbファイルの生成
モデルファイル（.fbx）を、Blenderを使用して**.glb**形式に変換します。

* インポートから、ダウンロードしたモデルファイルを選択します。
![スクリーンショット 2021-09-22 135754.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/9f5885c8-361d-7cf6-a6bc-e870c8e496c3.png)

* アニメーションファイルは、扱いやすいように一部ファイル名を変更します。
アクション名をファイル名と同じにしておきます。**walking.glb**を作成したいので、アクション名も**walking**に変更します。
![スクリーンショット 2021-09-22 140113.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/275cffe5-9531-4f6c-7c9e-8346796a384b.png)

* エクスポート方法については、以下を参照してください。

https://qiita.com/nemutas/items/6d89cb809d51fa8247ad#%E3%83%A2%E3%83%87%E3%83%AB%E3%83%95%E3%82%A1%E3%82%A4%E3%83%ABglb%E3%81%AE%E3%82%A8%E3%82%AF%E3%82%B9%E3%83%9D%E3%83%BC%E3%83%88

<!-- ========================================================== -->
# プロジェクトの作成
<!-- ========================================================== -->
* 任意のプロジェクトフォルダを作成して、以下を実行します。

```:cmd
npx create-react-app . --template typescript --use-npm
```

* 必要なパッケージをインストールします。

```:cmd
npm i three @react-three/fiber @react-three/drei
npm i -D @types/three
```

* コントローラー用のパッケージをインストールします。

```:cmd
npm i valtio
npm install @mui/material @mui/icons-material @emotion/react @emotion/styled
```

:::note warn
Material UIについて、v5.0.0になってパッケージ名が変更になったようです。
また、依存パッケージとして、@emotion/react・@emotion/styledをインストールする必要があります。
:::

https://mui.com/getting-started/installation/

<!-- ========================================================== -->
# 実装
<!-- ========================================================== -->
### モデルファイル
モデルファイルを以下の場所に配置します。

```
public/assets/ybot.glb
public/assets/animations/walking.glb
public/assets/animations/dancing.glb
```

### コード
#### params.ts
staticな値を格納します。

```.ts:src/params.ts
export const AnimationNames = ['walking', 'flair', 'dancing', 'dancing2', 'dynamic_pose'] as const

export const TextureNames = [
	'3A2412_A78B5F_705434_836C47',
	'293534_B2BFC5_738289_8A9AA7',
	'15100F_241D1B_292424_2C2C27',
	'161B1F_C7E0EC_90A5B3_7B8C9B',
	'191514_6D5145_4E3324_3B564D',
	'1B1B1B_999999_575757_747474',
	'1A2461_3D70DB_2C3C8F_2C6CAC',
	'253C3C_528181_406C6C_385F5F',
	'2E763A_78A0B7_B3D1CF_14F209',
	'C5A292_635247_F2D7D6_846A5B',
	'C7B9A1_F8F1E4_EEE4D2_E4D8C4',
	'C7C7D7_4C4E5A_818393_6C6C74',
	'C8C8C8_3F3F3F_787878_5C5C5C',
	'D07E3F_FBBD1F_8D2840_24120C',
	'D54C2B_5F1105_F39382_F08375',
	'E6BF3C_5A4719_977726_FCFC82',
	'F75F0B_461604_9A3004_FB9D2F',
	'FBB43F_FBE993_FB552E_FCDD65'
] as const
```
* AnimationNamesは、アニメーションのファイル名かつアクション名として使っています。
* TextureNamesは、テクスチャーのファイル名を格納しています。詳しくは**テクスチャー**で説明しています。

#### types.ts
globalで使用する型の定義をします。

```.ts:src/types.ts
import { AnimationNames, TextureNames } from './params';

export type AnimationName = typeof AnimationNames[number]

export type TextureName = typeof TextureNames[number]
```
* 配列に対して、`typeof 配列[number]`とすることで、**配列要素のUnion型**として型を定義できます。

#### store.ts
**valtio**を使用して、モデルの状態管理をします。

```.ts:src/store.ts
import { proxy } from 'valtio';
import { AnimationName, TextureName } from './types';

type Model = {
	animation: AnimationName
	texture: {
		body: TextureName
		joint: TextureName
	}
	isPaused: boolean
}

export const modelState = proxy<Model>({
	animation: 'walking',
	texture: {
		body: '293534_B2BFC5_738289_8A9AA7',
		joint: '3A2412_A78B5F_705434_836C47'
	},
	isPaused: true
})
```
* animationは、ファイル名かつアクション名を管理します。
* textureは、モデルのテクスチャーを管理します。詳しくは**テクスチャー**を参照してください。
* isPausedは、アニメーションの実行状態を管理します。（一時停止かどうか）
* proxyのなかで定義されている値は、それぞれの初期値になります。

#### useAnimation.ts
アニメーションをインポートし、実行するカスタムフックです。

```.tsx:src/useAnimation.ts
/* eslint-disable react-hooks/rules-of-hooks */

import { useEffect } from 'react';
import { useSnapshot } from 'valtio';
import { useAnimations, useGLTF } from '@react-three/drei';
import { AnimationNames } from './params';
import { modelState } from './store';

export const useAnimation = () => {
	const modelSnap = useSnapshot(modelState)

	const animationClips: THREE.AnimationClip[] = []
	AnimationNames.forEach(name => {
		const { animations } = useGLTF(`/assets/animations/${name}.glb`)
		animationClips.push(...animations)
	})

	const { actions, ref } = useAnimations(animationClips)

	// animation
	useEffect(() => {
		actions[modelSnap.animation]?.reset().fadeIn(0.5).play()

		return () => void actions[modelSnap.animation]?.fadeOut(0.5)
	}, [actions, modelSnap.animation])

	// pause
	useEffect(() => {
		const action = actions[modelSnap.animation]
		if (action) {
			action.paused = modelSnap.isPaused
		}
	}, [actions, modelSnap.animation, modelSnap.isPaused])

	return ref
}
```
* [**useAnimations**](https://docs.pmnd.rs/drei/abstractions/use-animations)は、最初に読み込んだデータをキャッシングしているようで、引数である`animationClips`の値が変わっても返す`actions`は変更されません。最初は状態管理しているanimationの変更に併せて動的に読み込ませる設計でしたが、うまくいきませんでした。（細かい仕様がわかってないです...）
そこで、最初にすべてのアニメーションを一度に読み込むようにしたら、うまくいきました。
実際に、[公式デモ](https://drei.pmnd.rs/?path=/story/abstractions-useanimations--use-animations-st)でもそのようにアニメーションを変更できるようにしています。

* **useEffect（animation）**では、アニメーションが切り替わったタイミングで、**reset()**をして`play()`を実行しています。
アクションをリセットすることで、アクションが切り替わったタイミングで前の情報を残さないようにします。（ないとバグります）
[AnimationAction.reset](https://threejs.org/docs/#api/en/animation/AnimationAction.reset)

* **useEffect（pause）**では、アクションの一時停止または実行を設定しています。

#### Model.tsx
3Dモデル及びアニメーションのインポートと描画を行います。
ファイルの雛形を、ybot.glbに対して[**gltfjsx**](https://github.com/pmndrs/gltfjsx)を使用して作成しています。gltfjsxの使用方法は以下を参照してください。

https://qiita.com/nemutas/items/6d89cb809d51fa8247ad#blender%E3%83%A2%E3%83%87%E3%83%AB%E3%81%AE%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%81%BF%E3%82%B3%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%8D%E3%83%B3%E3%83%88%E3%81%AE%E4%BD%9C%E6%88%90

```.tsx:src/Model.tsx
/*
Auto-generated by: https://github.com/pmndrs/gltfjsx
*/

import React, { VFC } from 'react';
import * as THREE from 'three';
import { GLTF } from 'three/examples/jsm/loaders/GLTFLoader';
import { useSnapshot } from 'valtio';
import { useGLTF, useMatcapTexture } from '@react-three/drei';
import { modelState } from './store';
import { useAnimation } from './useAnimation';

type GLTFResult = GLTF & {
	nodes: {
		Alpha_Joints: THREE.SkinnedMesh
		Alpha_Surface: THREE.SkinnedMesh
		mixamorigHips: THREE.Bone
	}
	materials: {
		Alpha_Joints_MAT: THREE.MeshStandardMaterial
		Alpha_Body_MAT: THREE.MeshStandardMaterial
	}
}

const ModelPath = '/assets/ybot.glb'

export const Model: VFC<JSX.IntrinsicElements['group']> = props => {
	const modelSnap = useSnapshot(modelState)
	const { nodes, materials } = useGLTF(ModelPath) as GLTFResult

	const groupRef = useAnimation()

	const [matcapBody] = useMatcapTexture(modelSnap.texture.body, 512)
	const [matcapJoints] = useMatcapTexture(modelSnap.texture.joint, 512)

	return (
		<group ref={groupRef} {...props} dispose={null}>
			<group name="Armature" rotation={[Math.PI / 2, 0, 0]} scale={0.02}>
				<primitive object={nodes.mixamorigHips} />
				{/* Joints */}
				<skinnedMesh
					castShadow
					geometry={nodes.Alpha_Joints.geometry}
					material={materials.Alpha_Joints_MAT}
					skeleton={nodes.Alpha_Joints.skeleton}>
					<meshMatcapMaterial attach="material" matcap={matcapJoints} />
				</skinnedMesh>
				{/* Body */}
				<skinnedMesh
					castShadow
					geometry={nodes.Alpha_Surface.geometry}
					material={materials.Alpha_Body_MAT}
					skeleton={nodes.Alpha_Surface.skeleton}>
					<meshMatcapMaterial attach="material" matcap={matcapBody} />
				</skinnedMesh>
			</group>
		</group>
	)
}
useGLTF.preload(ModelPath)
```
* アニメーションは、作成した**useAnimation**を使用して読み込みます。生成される**groupRef**を上位の`group`タグに割り当てます。

#### テクスチャー
3Dモデルのテクスチャーは、**Matcap**を使用します。

https://github.com/emmelleppi/matcaps

* Matcapを使用すると、**3Dモデルに影が落ちなくなります。**ただし、3Dモデル自体が他に落とす影は有効です。（`castShadow`は効く、`receiveShadow`は効かなくなる）

* Matcapは、4色（HexColorコードの組み合わせ）からなるテクスチャーです。そのコードを[**useMatcapTexture**](https://docs.pmnd.rs/drei/prototyping/use-matcap-texture)に指定してインポートします。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/bd374e3b-49ad-38b6-97dd-949c52578453.png" width="800">

#### Controller.tsx
valtioを使ったデータの受け渡しをしているだけなので、ご興味のある方は参考にしてください。

<details><summary>コード</summary><div>

```.tsx:src/Controller.tsx
import React, { VFC } from 'react';
import { useSnapshot } from 'valtio';
import { css } from '@emotion/css';
import PauseIcon from '@mui/icons-material/Pause';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import { Button, MenuItem, Select, SelectChangeEvent, Typography } from '@mui/material';
import { AnimationNames, TextureNames } from './params';
import { modelState } from './store';
import { AnimationName, TextureName } from './types';

export const Controller: VFC = () => {
	return (
		<div className={styles.container}>
			<div>
				<Typography className={styles.text}>Animation</Typography>
				<AnimationSelector />
			</div>

			<div>
				<Typography className={styles.text}>Body Texture</Typography>
				<TextureSelector textureType="body" />
			</div>

			<div>
				<Typography className={styles.text}>Joint Texture</Typography>
				<TextureSelector textureType="joint" />
			</div>

			<ControlButton />
		</div>
	)
}

// ==============================================
const AnimationSelector: VFC = () => {
	// const classes = useStyles()
	const modelSnap = useSnapshot(modelState)

	const handleChange = (event: SelectChangeEvent<AnimationName>) => {
		modelState.animation = event.target.value as AnimationName
	}

	return (
		<Select value={modelSnap.animation} fullWidth variant="standard" onChange={handleChange}>
			{AnimationNames.map((name, i) => (
				<MenuItem key={i} value={name}>
					{name}
				</MenuItem>
			))}
		</Select>
	)
}

// ==============================================
type TextureSelectorProps = {
	textureType: keyof typeof modelState.texture
}

const TextureSelector: VFC<TextureSelectorProps> = ({ textureType }) => {
	// const classes = useStyles()
	const modelSnap = useSnapshot(modelState)

	const handleChange = (event: SelectChangeEvent<TextureName>) => {
		modelState.texture[textureType] = event.target.value as TextureName
	}

	return (
		<Select
			value={modelSnap.texture[textureType]}
			fullWidth
			variant="standard"
			onChange={handleChange}>
			{TextureNames.map((name, i) => {
				const colors = name.split('_')
				return (
					<MenuItem key={i} value={name}>
						<div className={styles.texturePreviewContainer}>
							<div className={styles.texturePreview(colors[0])} />
							<div className={styles.texturePreview(colors[1])} />
							<div className={styles.texturePreview(colors[2])} />
							<div className={styles.texturePreview(colors[3])} />
						</div>
					</MenuItem>
				)
			})}
		</Select>
	)
}

// ==============================================
const ControlButton: VFC = () => {
	const modelSnap = useSnapshot(modelState)

	const clickHandler = () => {
		modelState.isPaused = !modelSnap.isPaused
	}

	return (
		<>
			{modelSnap.isPaused ? (
				<Button variant="contained" endIcon={<PlayArrowIcon />} onClick={clickHandler}>
					play
				</Button>
			) : (
				<Button variant="contained" endIcon={<PauseIcon />} onClick={clickHandler}>
					pause
				</Button>
			)}
		</>
	)
}

// ==============================================
const styles = {
	container: css`
		width: 300px;
		padding: 20px;
		background-color: rgba(255, 255, 255, 0.2);
		border-radius: 5%;
		display: flex;
		flex-direction: column;
		row-gap: 30px;
	`,
	text: css`
		color: orange;
	`,
	texturePreviewContainer: css`
		width: 100%;
		height: 20px;
		display: grid;
		grid-template-columns: repeat(4, auto);
	`,
	texturePreview: (color: string) => css`
		width: 100%;
		height: 100%;
		background-color: #${color};
	`
}
```
</div></details>

#### YBot.tsx
Canvasへの追加をしています。

```.tsx:src/YBot.tsx
import React, { Suspense, VFC } from 'react';
import { css } from '@emotion/css';
import { OrbitControls } from '@react-three/drei';
import { TDirectionalLight } from '../objects/TDirectionalLight';
import { TFloorPlane } from '../objects/TFloorPlane';
import { TCanvas } from '../TCanvas';
import { Controller } from './Controller';
import { Model } from './Model';

export const YBot: VFC = () => {
	return (
		<div className={styles.container}>
			<TCanvas>
				{/* control */}
				<OrbitControls />
				{/* light */}
				<TDirectionalLight position={[5, 5, 5]} />
				{/* model */}
				<Suspense fallback={null}>
					<Model />
				</Suspense>
				{/* objects */}
				<TFloorPlane />
			</TCanvas>

			<div className={styles.controller}>
				<Controller />
			</div>
		</div>
	)
}

const styles = {
	container: css`
		position: relative;
		width: 100%;
		height: 100%;
	`,
	controller: css`
		position: absolute;
		top: 20px;
		left: 20px;
	`
}
```

* `TCanvas`、`TDirectionalLight`、`TFloorPlane`は、react-three-fiberのコンポーネントをラップしたものです。詳しくは、**リポジトリ**を参照してください。

<!-- ========================================================== -->
# リポジトリ
<!-- ========================================================== -->
再編したものなのでディレクトリ構成だけ少し違いますが、おおむね記事通りです。

https://github.com/nemutas/app-mixamo-animation

:::note warn
GitHubにソースをpushするだけなら大丈夫ですが、
GitHub Pagesに登録しようとすると、なぜか.glbファイルを読み込めなくなり、アプリ表示時にエラーになります。
なので、アプリケーションのデプロイは、Firebaseにしています。
:::

<!-- ========================================================== -->
# まとめ
<!-- ========================================================== -->
Mixamoモデル以外を使う場合でも実装方法は変わらないと思います。
次は、自分で作ったボーンモデルをアプリケーション内でコントロールできるようにしたいです。

<!-- ========================================================== -->
# 参照
<!-- ========================================================== -->
* 公式デモ

https://drei.pmnd.rs/?path=/story/abstractions-useanimations--use-animations-st

* Mixamo

https://www.mixamo.com

* MatCap Texture

https://github.com/emmelleppi/matcaps