Three.js, gsapを使って発表シミュレーターを作ってみたんですよ

![スクリーンショット 2021-12-09 10.00.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2321549/2dfc6ac9-c33d-68a3-963e-73791b45d15b.png)
# 概要
記念すべき初投稿に、発表シミュレーターを作ってみました。

何かを発表したい時、ありませんか？
クイズの正答、好きな食べ物、日頃言えていないこと....色々ありますね。
そんな時に役立つアプリを作ってみましたので、ご紹介いたします。

# 伝えたいこと
Three.js, gsap未経験の私でもそれっぽいものを作れたので、
その経験や知識を共有できたらと思ってます。
アプリやソースコードも公開しているので、
Three.js, gsapまわりを使ってみたい方にとって参考になれば幸いです。

# 発表シミュレーターについて
以下のリンクで公開しています。

https://happyo-app.vercel.app/

ソースコードは[こちら](https://github.com/reo-morisawa/happyo-app)です。

## 使い方
1. 発表したいテキストを入力欄に入力する
2. 「テキスト決定」ボタンを押す
3. 心の準備ができたら、「シミュレートする」ボタンを押す（音が出ます。ご注意ください）

たったこれだけ。好きな言葉で発表してみましょう。

## 環境について 
- JavaScript
- react（create-react-appを使用）
- Three.js
- gsap
- npm 6.14.4
- vercel

ライブラリ自体については、npmでインストールしました。

```
  npm install three
  npm install gsap
```

## コード解説
アプリの一部を抜粋しながら、Three.js, gsapについて解説していきます。
※以降を書き進めてもアプリは完成しないので、アプリをいじりたい場合は[ソースコード](https://github.com/reo-morisawa/happyo-app)のクローンをオススメします。

Three.jsでは、3Dでのレンダリングが可能です。
画面のレンダリングで用意するものは以下です。

- レンダラー
- シーン（空間）
- 物体
- カメラ

### レンダラー ・ シーン
まず、基礎部分として以下のコードで`レンダラー`、`シーン`を生成します。

```jsx:src/js/App.js
import { WebGLRenderer, Color, Scene } from 'three';

const App = () => {
  const animate = (text) => {
    /* 基礎部分の生成 */
    const { innerWidth, innerHeight } = window;
    // レンダラーを生成
    const renderer = new WebGLRenderer();
    // レンダラーのサイズを設定
    renderer.setSize(innerWidth, innerHeight);
    const canvas = document.getElementById('canvas-container');
    canvas.appendChild(renderer.domElement);
    // シーンを生成
    const scene = new Scene();
    scene.background = new Color('black');
  }
}
```

① 以下で`レンダラー`を生成。サイズはウインドウの幅に設定しています。

```jsx:src/js/App.js
    /* 基礎部分の生成 */
    const { innerWidth, innerHeight } = window;
    // レンダラーを生成
    const renderer = new WebGLRenderer();
    // レンダラーのサイズを設定
    renderer.setSize(innerWidth, innerHeight);
    const canvas = document.getElementById('canvas-container');
    canvas.appendChild(renderer.domElement);
```

② `シーン`を生成。背景は黒で設定しています。

```jsx:src/js/App.js
    // シーンを生成
    const scene = new Scene();
    scene.background = new Color('black');
```

これで何もない空間が出来上がります。

### テキスト
次は、空間に物体を追加します。
このアプリでは、物体として

- 床
- テキスト
- ライト
- (ライトを当てる対象)

を追加しますが、
本記事では`テキスト`について解説します。
以下コードです。

```jsx:src/js/App.js
import fontJson from '../assets/fonts/GenEi_Antique_Pv5_Regular.typeface.json';
import { TextModel } from './text';

const App = () => {
  const animate = (text) => {
    /* 省略 */

    // 文字列を生成
    const textModel = new TextModel({ 
      text, // ユーザーが入力したテキスト
      fontJson // json形式のfont
    });
    // シーンに追加
    scene.add(textModel.mesh);
  }
}
```
```js:src/js/text.js
import { MeshPhongMaterial, Mesh } from "three";
import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js';
import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js';

export class TextModel {
  constructor({ text, fontJson, positionY = 15 }) {
    const loader = new FontLoader();
    // フォントをロードする
    const font = loader.parse(fontJson);

    this.positionY = positionY;
    this.geometry = this.makeGeometry(text, font);
    this.materials = this.makeMaterials();
    this.mesh = this.makeMesh(this.geometry, this.materials);
    this.align(this.positionY);
  }

  // 配置を直す
  align = (positionY = 0) => {
    if (!this.geometry || !this.mesh) return;
    this.geometry.center();
    this.mesh.position.setY(positionY);
  }

  // 文字列メッシュを作成して返す
  makeMesh = (geometry, materials) => {
    // ジオメトリとマテリアルを渡してメッシュを生成。これをシーンに追加することで画面に表示できる。
    return new Mesh(geometry, materials);
  }

  // 文字自体に関するオブジェクトを作成して返す
  makeGeometry = (text, font) => {
    // font: json形式のfont
    // size: サイズ
    // height: 高さ
    // curveSegments: 曲線上のポイントの数。数値が高いと文字オブジェクトが丸くなる
    // bevelxxxxx: bevelは傾斜とか斜角という意味だが、ここら辺は見ながら設定
    return new TextGeometry(text, {
      font: font,
      size: 5,
      height: 3,
      curveSegments: 1,
      bevelEnabled: true,
      bevelThickness: 1,
      bevelSize: 1,
      bevelOffset: 0,
      bevelSegments: 1
    });
  }

  // 材質のオブジェクトを作成して返す
  makeMaterials = (textColor = Math.random() * 0xffffff, backgroundColor = 0x000000) => {
    // [ 文字の材質, 文字まわり（背景）の材質 ]
    // MeshPhongMaterial: 光沢のある材質, MeshLambertMaterial: マットな材質, MeshStandardMaterial: 中間くらいの材質
    // color: 色
    return [
      new MeshPhongMaterial({ color: textColor }),
      new MeshPhongMaterial({ color: backgroundColor })
    ];
  }
}

```

③ geometry, materialsを元にmeshを生成。

- geometry: 文字自体のオブジェクト。
- materials: 文字の材質についてのオブジェクト。
- mesh: geometry, materialsを元に作られた文字のオブジェクト。

```js:src/js/text.js
    this.geometry = this.makeGeometry(text, font);
    this.materials = this.makeMaterials();
    this.mesh = this.makeMesh(this.geometry, this.materials);
```

④ 以下コードで`シーン`に追加。

```jsx:src/js/App.js
    // シーンに追加
    scene.add(textModel.mesh);
```

以上でテキストの生成は終わりです。

### カメラ
次は`カメラ`を追加します。
以下コードです。

```jsx:src/js/App.js
import { CameraDriver } from './camera';

const App = () => {
  const animate = (text) => {
    // カメラを生成
    const cameraDriver = new CameraDriver({ 
      cameraParams: { fov: 45, aspect: 2, near: 0.1, far: 100 }, 
      textModel, // テキストのインスタンス
      lightDriver, // ライトのインスタンス
      targetDriver, // ライトを当てる対象のインスタンス
      playAudio // 音楽を流す関数
    });
  }
}
```
```js:src/js/camera.js
import { PerspectiveCamera } from "three";
import { gsap } from "gsap";

/* カメラを動かす */
export class CameraDriver {
  constructor({ cameraParams, textModel, lightDriver, targetDriver, playAudio }) {
    this.cameraModel = new CameraModel(cameraParams);
    this.textModel = textModel;
    this.lightDriver = lightDriver;
    this.targetDriver = targetDriver;
    this.animationComplete = false;
    this.playAudio = playAudio;
  }

  animate = () => {
    /* 省略 */
  }
}

/* カメラ */
export class CameraModel {
  constructor({ fov, aspect, near, far, position }) {
    this.camera = this.makeCamera({ fov, aspect, near, far, position });
  }

  // カメラを生成
  makeCamera = ({ fov = 45, aspect = 2, near = 0.1, far = 100, position = { x: 0, y: 0, z: 0 } }) => {
    // fov: 視野角
    // aspect: アスペクト比
    // near: 設定した近さまでカメラに映る
    // for: 設定した遠さまでカメラに映る
    const camera = new PerspectiveCamera(fov, aspect, near, far);
    // カメラのポジションをセット
    camera.position.set(position.x, position.y, position.z);
    return camera;
  }
}
```

⑤ ここで`カメラ`を生成。
なお、`カメラ`についてはシーンに追加する必要はありませんが、アニメーション部分で必要になります。

```js:src/js/camera.js
  // カメラを生成
  makeCamera = ({ fov = 45, aspect = 2, near = 0.1, far = 100, position = { x: 0, y: 0, z: 0 } }) => {
    // fov: 視野角
    // aspect: アスペクト比
    // near: 設定した近さまでカメラに映る
    // for: 設定した遠さまでカメラに映る
    const camera = new PerspectiveCamera(fov, aspect, near, far);
    // カメラのポジションをセット
    camera.position.set(position.x, position.y, position.z);
    return camera;
  }
```
※PerspectiveCameraの他にも`カメラ`の種類があります。詳しくは以下リンクを参照ください。
[Three.jsのカメラ](https://ics.media/tutorial-three/camera_variation/)


### アニメーション
最後にアニメーションです。

```jsx:src/js/App.js
import { CameraDriver } from './camera';

const App = () => {
  const animate = (text) => {
    /* 省略 */

    // 毎フレームごとに動く
    const render = () => {
      /* 省略 */
  
      renderer.render(scene, cameraDriver.cameraModel.camera);
  
    // 次のアニメーションをリクエスト
      requestAnimationFrame(render);
    }
    // 次のアニメーションをリクエスト
    requestAnimationFrame(render);
  }
}
```

⑥ ①で生成した`レンダラー`によってレンダリング。第一引数は`シーン`、第二引数には`カメラ`をとります。

```jsx:src/js/App.js
      renderer.render(scene, cameraDriver.cameraModel.camera);
```

⑦ requestAnimationFrameによってrender関数が約毎フレームごとに動きます。

```jsx:src/js/App.js
    // 次のアニメーションをリクエスト
    requestAnimationFrame(render);
```
render関数の中にアニメーション処理を書くことになるのですが、
gsapを使えば毎フレームごとの管理をせずに済む＆複雑な動きが可能になるので、便利です。
gsapを使用したコードは以下です。

```js:src/js/camera.js
import { PerspectiveCamera } from "three";
import { gsap } from "gsap";

/* カメラを動かす */
export class CameraDriver {
  /* 省略 */

  // カメラのアニメーションを実行
  animate = () => {
    if (!this.cameraModel || !this.textModel || !this.lightDriver || !this.targetDriver) return;
    const { camera } = this.cameraModel; // カメラ
    const { positionY } = this.textModel; // テキストのy座標
    const { complete } = this.lightDriver; // カメラのアニメーション終了をライトに通知するための関数
    const { front, back } = this.targetDriver.targetModel; // ライトを当てる対象
    // timelineはgsapの機能。アニメーションのタイムラインを生成。
    // gsap.timelineには以下のようなオプションも付けられる。
    // repeat: タイムラインを繰り返す回数。-1を設定すると無期限に繰り返す。
    // yoyo: trueの場合ヨーヨーのようにアニメーションを前後する。（1=>2=>3=>2=>1とタイムライン上のアニメーションを往復する）
    // delay: アニメーションを始めるまでの遅延時間
    const timelineCamera = gsap.timeline();
    // .set、.to、(.from, .fromTo)をつなげることでアニメーションを連続して再生できる。
    // .setと.toの違い: 物体を指定の位置まで移動するのは共通だが、.setはアニメーションなし、.toはアニメーションなしで移動する。
    timelineCamera
      .set(camera.position, {
        x: 0, // x座標
        y: 50, // y座標
        z: 0, // z座標
        delay: 0, // 遅延時間
        onComplete: () => { // 動作が完了したら実行する
          this.playAudio(); // 音楽を流す
          camera.lookAt(0, 0, 0); // 指定の位置にカメラを向かせる
        }
      })
      .to(camera.position, {
        x: 0,
        y: 40,
        z: 0,
        duration: 1.8, // アニメーションにかける時間
        delay: 0,
        ease: "sine.inOut", // アニメーションのイージング
      })
      .set(camera.position, {
        x: 15,
        y: positionY,
        z: 40,
        delay: 0,
        onComplete: () => {
          camera.lookAt(10, 10, 0);
        }
      })
      .to(camera.position, {
        x: 15,
        y: positionY,
        z: 10,
        duration: 2,
        delay: 0,
        ease: "sine.inOut",
      })
      .set(camera.position, {
        x: -15,
        y: positionY,
        z: 40,
        delay: 0,
        onComplete: () => {
          camera.lookAt(-10, 10, 0)
        }
      })
      .to(camera.position, {
        x: -15,
        y: positionY,
        z: -10,
        duration: 2.6,
        delay: 0,
        ease: "sine.in",
      })
      .set(camera.position, {
        x: -30,
        y: positionY,
        z: 40,
        delay: 0,
        onComplete: () => {
          camera.lookAt(-30, 10, 0)
        }
      })
      .to(camera.position, {
        x: 30,
        y: positionY,
        z: 40,
        duration: 4.2,
        delay: 0,
        ease: "sine.in",
      })
      .set(camera.position, {
        x: 0,
        y: positionY,
        z: 0,
        delay: 0,
        onComplete: () => {
          camera.lookAt(0, positionY, 0)
          this.animationComplete = true;
        }
      })
      .to(camera.position, {
        x: 0,
        y: positionY,
        z: 40,
        duration: 0.5,
        delay: 0,
        ease: "sine.in"
      }, 'focus') // 第3引数に文字列を入れると、同じ文言の入っているアニメーションと同時に実行する。
      .to(back.target.position, {
        x: 0,
        y: positionY,
        z: 0,
        duration: 0.5,
        delay: 0,
        ease: "sine.in"
      }, 'focus')
      .to(front.target.position, {
        x: 0,
        y: positionY - 2,
        z: 0,
        duration: 0.5,
        delay: 0,
        ease: "sine.in",
        onComplete: () => {
          complete();
        }
      }, 'focus');
    this.timeline = timelineCamera;
  }
}
  
```

⑧ 上記コードでカメラのアニメーションを実行しています。アニメーションをざっくり解説すると、

    1. カメラを上から下に移動
    2. カメラを右後方から右前方へ移動
    3. カメラを左後方から左前方へ移動
    4. カメラを左後方から右後方へ移動
    5. カメラを文字正面から後方へ移動＆ライトを当てる対象を中心へ移動

となっており、複雑なアニメーションを一回呼び出すだけで実行してくれます。

以下の部分を数珠つなぎにすることでアニメーションを順番に実行しています。

```js:src/js/camera.js
      .to(camera.position, {
        x: 0, // x座標
        y: 40, // y座標
        z: 0, // z座標
        duration: 1.8, // アニメーションにかける時間
        delay: 0, // 遅延時間
        ease: "sine.inOut", // アニメーションのイージング
      })
```

## 振り返り
Three.js、gsapの触りの部分を寄せ集めるだけで、
それっぽいアニメーションを作ることができてとても良かったです。
リッチな表現技法として、これらの技術を学んで損はなかったと思います！
何より見た目で成果がわかるので、モチベーションもずっと保ててました。
まだまだ知識が浅すぎるので、もっと深く学んでいきたいです。
（あと、発表シミュレーターのテキスト入力のUIもなんとかしたい）

以上です。
ビバ、アニメーション！！！



## 参考資料
[Three.jsとGSAPを組み合わせてボックスを回転させるだけ](https://blog.mismith.me/entry/2021/07/05/221739)
[最新版で学ぶThree.js入門 手軽にWebGLを扱える3Dライブラリ](https://ics.media/entry/14771/)
[Three.jsのカメラ](https://ics.media/tutorial-three/camera_variation/)
[GSAP(基本機能)](https://qiita.com/takeshisakuma/items/97a7f702ec3c4f656525)
[フロントエンドから始めるアニメーション 最強のライブラリGSAP3を手に入れよう](https://ics.media/entry/200805/)
[GSAP to, from, fromTo, setの使い方](http://13creativ.com/gsap-to-from-fromto-set/)