React (Typescript) + react-three-fiber + three-vrmでVRMモデルを表示してみる

手軽に3Dモデルを使ってみたい。でもUnityは学習コスト高そうだし、手慣れたWeb系の言語でどうにかできないものか。
というわけで今回、タイトルにあるライブラリを使ってVRMモデルを表示させてみました。

なおここでは、React環境の作り方などの説明は割愛します。

## 環境
| Package | Version | Explanation |
|---|---|---|
| React | 16.13 | りあくと
| Typescript | 3.8.3 | たいぷすくりぷと |
| [Three](https://www.npmjs.com/package/three) | 0.115.0 | WebGLを使ってJSで3Dモデルを描画できるライブラリ |
| [react-three-fiber](https://www.npmjs.com/package/react-three-fiber) | 4.0.27 | React上でThree.jsを手軽に使えるやーつ |
| [@pixiv/three-vrm](https://github.com/pixiv/three-vrm/) | 0.3.3 | Three.js上で簡単にVRMファイルを扱える便利なやつ |

`react-three-renderer`というのもあるが凍結しているようで、開発陣も「代わりにfiber応援してネ」って言ってる。ちなみに`react-three-fiber`はReactNativeにも対応しているらしい。

## そもそもVRMファイルとは

ドワンゴが作った、人型3Dデータ用のファイル形式です。
最近VRChatを始めいろんなところで3Dモデルの需要が増えている中で、ソフトごとに形式が違ってはせっかく作った3Dキャラもったいない！ということで作られたフォーマットらしいです。画像の `jpg`とか`png`とか、ハードウェアで言うなら`USB`とかと同じですね。
共通フォーマットができたことで、[Vroid Hub](https://hub.vroid.com/)みたいな、有志の方が作成した3Dキャラを手軽に見ることができるようになったり、中には無償で配布されているVRMファイルもあります！


# やってみる

## 0. React環境を作る

`create-react-app`でも大丈夫です。

## 1. パッケージを追加

とりあえずパッケージを追加する。

```shell
yarn add -D three react-three-fiber @pixiv/three-vrm @types/three
```

## 2. 一旦`react-three-fiber`に慣れる

初めて使うので、一旦適当なオブジェクトを表示してみよう。と言うことで、立方体を表示してみます。

```tsx:App.tsx
import React from 'react'
import { Canvas } from 'react-three-fiber'
import styled from 'styled-components'

import SampleBox from 'components/SampleBox'

export default function App() {
  return (
    <Container>
      <Canvas>
        <SampleBox />
      </Canvas>
    </Container>
  )
}

const Container = styled.div`
  width: 100vw;
  height: 100vh;
`
```

```tsx:SampleBox.tsx
import React, { useRef } from 'react'
import { Mesh } from 'three'
import { useFrame } from 'react-three-fiber'

export default function SampleBox() {
  const ref = useRef({} as Mesh)
  useFrame(() => (ref.current.rotation.z += 0.01))

  return (
   <mesh ref={ref}>
      <boxBufferGeometry attach='geometry' />
      <meshBasicMaterial attach='material' color='hotpink' opacity={0.5} transparent />
    </mesh>
  )
}
```

`SampleBox`コンポーネントが立方体です。
`useFrame`を使うことで、毎フレームごとにオブジェクトの位置や向きを変えられます。今回は向きのz座標を0.01ずつ足すことで回転を加えています。

ちなみに`StyledComponent`は、コンポーネントに直接styleを適用出来るライブラリです。個人的に可読性が爆上がりするのでオススメ。

これを実行したのがこちら
![image](https://i.gyazo.com/1f9a5cae7da1b6aca91ece2f00aa940d.gif)

ピンクの立方体(?)がくるくる回っていますね。
しかしこのままだと立方体なのか平面なのかよくわからないので、カメラの位置をマウスで動かせるようにしましょう。

## 3. `OrbitControl`を追加する

マウスで拡大したり、カメラのアングルを自由に変えられるようにしましょう。
って言うのが`OrbitControl`です。

実は`react-three-fiber`にあるんですが、これをtypescriptで使うと型参照できずエラーに。そこで型を定義しつつコンポーネントを作ってラップします。
https://github.com/react-spring/react-three-fiber/issues/27

```tsx:Controls.tsx
import React, { useRef } from 'react'
import { extend, ReactThreeFiber, useThree, useFrame } from 'react-three-fiber'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'

extend({ OrbitControls })

declare global {
  namespace JSX {
    interface IntrinsicElements {
      readonly orbitControls: ReactThreeFiber.Object3DNode<OrbitControls, typeof OrbitControls>
    }
  }
}

export default function Controls(props: ReactThreeFiber.Object3DNode<OrbitControls, typeof OrbitControls>) {
  const {
    camera,
    gl: { domElement }
  } = useThree()
  const controls = useRef({} as OrbitControls)
  useFrame(() => controls.current.update())
  return <orbitControls {...props} ref={controls} args={[camera, domElement]} />
}
```

```diff_tsx:App.tsx
import React from 'react'
import { Canvas } from 'react-three-fiber'
import styled from 'styled-components'

import SampleBox from 'components/SampleBox'
+ import Controls from 'utils/Controls'

export default function App() {
  return (
    <Container>
      <Canvas>
        <SampleBox />
+        <Controls />
+        <gridHelper /> {/* わかりやすいようにGridPanelを表示 */}
      </Canvas>
    </Container>
  )
}

const Container = styled.div`
  width: 100vw;
  height: 100vh;
`
```

使い方としては、`<Canvas>`内で呼び出すだけ。実行してみましょう。
[![Image from Gyazo](https://i.gyazo.com/c3cbc11752d59b131d712fef4f80e1ef.gif)](https://gyazo.com/c3cbc11752d59b131d712fef4f80e1ef)
マウスで動かすことができました！

## 4. VRMを描画する

ここからがやっと本題。

### 4-1. モデルファイルを配置する

まずは描画するためのVRMを用意しましょう。
今回はサンプルとして、`three-vrm`に入っている女の子をお借りします。可愛い。黒髪ロング最高。
https://github.com/pixiv/three-vrm/blob/dev/examples/models/three-vrm-girl.vrm
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/265811/2f380aab-08e2-c28f-9aed-75538b18186e.png)

用意したVRMファイルはsrcではなく、distやpublicなどの配信ディレクトリに配置しましょう。
イメージとしては以下のような感じ。

```plaintext
├── public/
│   ├── models/
│   │   └── [ ファイル名 ].vrm	<-- ここ
│   ├── bundle.js
│   └── index.html
├── src/
│   ├── components/
│   ├── App.tsx
│   └── index.tsx
├── package.json
├── webpack.config.js
└── tsconfig.json
```

### 4-2. VRMコンポーネントを作成

VRMファイルを表示するためのコンポーネントを作ります。

```tsx:VRMAsset.tsx
import React, { useState, useEffect } from 'react'
import { useLoader } from 'react-three-fiber'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { VRM, VRMUtils, VRMSchema } from '@pixiv/three-vrm'
import { Scene, Group } from 'three'

interface Props {
  url: string
}

export default function VRMAsset({ url }: Props) {
  const [scene, setScene] = useState<Scene | Group | null>(null)
  const gltf = useLoader(GLTFLoader, url)

  useEffect(() => {
    VRMUtils.removeUnnecessaryJoints(gltf.scene)
    VRM.from(gltf).then(vrm => {
      setScene(vrm.scene)
      // 初期描画で背中が映ってしまうので向きを変えてあげる
      const boneNode = vrm.humanoid?.getBoneNode(VRMSchema.HumanoidBoneName.Hips)
      boneNode?.rotateY(Math.PI)
    })
  }, [gltf, setScene])

  if (scene === null) {
    return null
  }

  return <primitive object={scene} dispose={null} />
}
```

```tsx:SampleModel.tsx
import React, { Suspense } from 'react'
import VRMAsset from 'utils/VRMAsset'

export default function SampleModel() {
  return (
    <Suspense fallback={null}>
      <VRMAsset url='./models/model.vrm' />
    </Suspense>
  )
}
```
Propsで、VRMファイルのurlを受け取ります。ここに、先ほど配置したファイルのパスを指定します。
また`VRM.from`はPromiseを返すので、Suspenseで囲ってあげましょう（本当はここでローディングを出すとそれっぽくなったりする）

```diff:App.tsx
import React from 'react'
import { Canvas } from 'react-three-fiber'
import styled from 'styled-components'
+ import SampleModel from 'components/SampleBox' 
- import SampleBox from 'components/SampleBox'

import Controls from 'utils/Controls'

export default function App() {
  return (
    <Container>
      <Canvas>
+        <SampleModel />
-        <SampleBox />
        <Controls />
+       <directionalLight position={[1, 1, 1]} />
        <gridHelper />
      </Canvas>
    </Container>
  )
}

const Container = styled.div`
  width: 100vw;
  height: 100vh;
`
```
さっきまで試してた`SampleBox`を、新しく作った`SampleModel`に置き換えます。

それからもう一つ。**照明を追加しましょう。**
`<directionalLight>`ってやつです。これがないと、真っ黒いモデルが出てきます。僕はこれを忘れて、小一時間「ポケモンだ〜れだ」をやってました。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">React + react-three-fiber + three-vrmを使ってVRMを表示してみたけど…<br><br>テクスチャが表示されない🙄 <a href="https://t.co/ZizEldydnZ">pic.twitter.com/ZizEldydnZ</a></p>&mdash; sho (@sskmy1024r) <a href="https://twitter.com/sskmy1024r/status/1248491877382291459?ref_src=twsrc%5Etfw">April 10, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

それではいざ、実行。
[![Image from Gyazo](https://i.gyazo.com/840c3342f1df365a3ea0a8468f105541.gif)](https://gyazo.com/840c3342f1df365a3ea0a8468f105541)

できたー！ :tada: :tada: :tada: 

## 番外編

今のままだと、カメラの初期位置が低すぎたり遠すぎたりするので、そこら辺を調整しました。最終的なコードがこちら。

```tsx:Controls.tsx
import React, { useRef, useEffect } from 'react'
import { extend, ReactThreeFiber,  useThree, useFrame } from 'react-three-fiber'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'

extend({ OrbitControls })

declare global {
  namespace JSX {
    interface IntrinsicElements {
      readonly orbitControls: ReactThreeFiber.Object3DNode<OrbitControls, typeof OrbitControls>
    }
  }
}

interface Props extends ReactThreeFiber.Object3DNode<OrbitControls, typeof OrbitControls> {
  defaultCameraPosition?: [number, number, number]
}

export default function Controls(props: Props) {
  const {
    camera,
    gl: { domElement }
  } = useThree()
  const controls = useRef<OrbitControls>()
  const { defaultCameraPosition } = props

  useFrame(() => controls.current?.update())

  useEffect(() => {
    if (defaultCameraPosition !== undefined) {
      camera.position.set(...defaultCameraPosition)
    }
  }, [camera, defaultCameraPosition])

  return <orbitControls ref={controls} args={[camera, domElement]} screenSpacePanning {...props} />
}
```

```tsx:App.tsx
import React from 'react'
import { Canvas } from 'react-three-fiber'
import styled from 'styled-components'

import Controls from 'utils/Controls'
import SampleModel from 'components/SampleModel'
import { Vector3 } from 'three'

export default function App() {
  return (
    <Container>
      <Canvas>
        <SampleModel />
        <Controls defaultCameraPosition={[0, 1.25, 1]} target={new Vector3(0, 1, 0)} />
        <directionalLight position={[1, 1, 1]} />
        <gridHelper />
      </Canvas>
    </Container>
  )
}

const Container = styled.div`
  width: 100vw;
  height: 100vh;
`
```

`defaultCameraPosition`（名前長い）を追加して、初期カメラ位置を指定出来るように。

### 最終結果
[![Image from Gyazo](https://i.gyazo.com/4daddf1225ecfcb7b110949cc50b45bf.gif)](https://gyazo.com/4daddf1225ecfcb7b110949cc50b45bf)
モデルとちょうどいい位置にカメラをおくことができました :tada: :tada: :tada: 


# まとめ

VRコンテンツが増える中で、3Dモデルはもっと身近なものになっていく気がします。というかすでに乗り遅れている気が……。
そんな3Dモデルを手軽に扱えて、しかもブラウザ上で動くなんていいですね！

今度はこの3Dモデルを動かしてみたいと思うので、また進展があれば記事にしてみたいと思います。ではでは〜