three.js超入門 第3回 マウスやスクロールでのインタラクション

# 概要
この記事では「three.js超入門」と題して、three.jsの基礎からシェーダーの利用までをやっていきます。
ターゲットは主に「canvas表現を触ったことがないフロントエンドエンジニア」を想定しているので、jsの構文などの説明は省略しています。
three.jsのバージョンは執筆時点で最新の[r98](https://github.com/mrdoob/three.js/releases/tag/r98)を使用します。

[three.js超入門 第0回 3Dコンピュータグラフィックスの基礎](https://qiita.com/watabo_shi/items/f7c559c3cdbcdd0f2629)
[three.js超入門 第1回 レンダリングまでの流れ](https://qiita.com/watabo_shi/items/bf9bcd4569b6d480c608)
[three.js超入門 第2回 アニメーションと時間ベースでの制御](https://qiita.com/watabo_shi/items/1c3c50429279eedec04e)
three.js超入門 第3回 マウスやスクロールでのインタラクション
[three.js超入門 第4回 getBoundingClientRect()を使ったDOM要素との連携](https://qiita.com/watabo_shi/items/9ab12f62434a1e858226#_reference-a437d85928b89c9bb5e5)
[three.js超入門 第5回 シェーダー(GLSL)の基礎](https://qiita.com/watabo_shi/items/7325948db5c07f6fe9c7)
[three.js超入門 第6回 ShaderMaterialでメッシュを変形、着色する](https://qiita.com/watabo_shi/items/c0d4ef11cba6f21189ed)
[three.js超入門 第7回 シェーダーに変数を渡す](https://qiita.com/watabo_shi/items/1b48ca54a9034f3614d8)
[three.js超入門 第8回 シェーダーをインタラクティブに動かす](https://qiita.com/watabo_shi/items/39d21d53ca16541fd498)
[three.js超入門 第9回 シェーダーでテクスチャにエフェクトをかける](https://qiita.com/watabo_shi/items/2fc671f2147e799787f9)

[リポジトリ](https://github.com/watab0shi/threejs-workshop)

[前回](https://qiita.com/watabo_shi/items/1c3c50429279eedec04e)は`requestAnimationFrame`を使って3Dオブジェクトをアニメーションさせました。
今回はマウスやスクロールなどのイベントから取れる値を使って3Dオブジェクトをインタラクティブに動かしてみます。

# 下準備
## ウィンドウとWebGLの座標を統一させる
前回のコードでは、以下のようにカメラの距離とメッシュのサイズを目測で決めていましたが、今回はウィンドウのマウスイベントなどを使ってインタラクションを実装していくため、`WebGL`の座標の単位をウィンドウと同じ`px`に統一します。

↓カメラの撮影範囲や位置、ジオメトリのサイズが目測値になっていた。

```javascript
import { PerspectiveCamera } from 'three/src/cameras/PerspectiveCamera';

// カメラを作成 (視野角, 画面のアスペクト比, カメラに映る最短距離, カメラに映る最遠距離)
this.camera = new PerspectiveCamera(60, this.w / this.h, 1, 10);
this.camera.position.z = 3;// カメラを遠ざける
```
```javascript
// 立方体のジオメトリを作成(幅, 高さ, 奥行き)
const geo = new BoxGeometry(1, 1, 1);
```

### ウィンドウサイズの平面がぴったり収まるカメラ距離
![camera_dist.png](https://qiita-image-store.s3.amazonaws.com/0/86966/fc1a5b06-0438-f661-3bf4-079462b39f2a.png)
直角三角形のひとつの角（`fov/2`）と、角の反対側の辺の長さ（`height/2`）がわかっていて、底辺の`dist`を求めたいので、三角関数の`tan`の定義に当てはめて値を求めます。

```javascript
// 視野角をラジアンに変換
const fov    = 60;
const fovRad = (fov / 2) * (Math.PI / 180);

// 途中式
// Math.tan(fovRad)        = (height / 2) / dist;
// Math.tan(fovRad) * dist = (height / 2);
const dist = (height / 2) / Math.tan(fovRad);
```

## ピクセル座標で3Dオブジェクトを再配置
カメラを作成するところを以下のように変更して、ジオメトリの大きさとライトの位置もあわせて変更します。

```javascript
const fov    = 60;
const fovRad = (fov / 2) * (Math.PI / 180);// 視野角をラジアンに変換
const dist   = (this.h / 2) / Math.tan(fovRad);// ウィンドウぴったりのカメラ距離

// カメラを作成 (視野角, 画面のアスペクト比, カメラに映る最短距離, カメラに映る最遠距離)
this.camera = new PerspectiveCamera(fov, this.w / this.h, 1, dist * 2);
this.camera.position.z = dist;// カメラを遠ざける
```

```javascript
this.light.position.set(400, 400, 400);// ライトの位置を設定
```

```javascript
// 立方体のジオメトリを作成(幅, 高さ, 奥行き)
const geo = new BoxGeometry(300, 300, 300);
```

見た目に大きな変化はありませんが、大きさや座標を`px`指定できるようになりました。
![box-3.png](https://qiita-image-store.s3.amazonaws.com/0/86966/b33559f8-802a-1b79-ab63-307fe4da2e92.png)

# マウスでのインタラクション
コードを書く前に現時点での`window`座標と`WebGL`座標の対応関係を確認しましょう。
`PerspectiveCamera`を作る際に位置の指定をしていないので、原点`(0, 0, 0)`を見据えたまま、ウィンドウサイズがぴったり収まるところまで後ろに下がっているので、以下のようになっています。

※注意：`WebGL`ではy軸の向きが上向きになります。
![coordinate2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/86966/e7c76b80-1e37-f0b5-a35a-b6f236003321.png)


## マウス座標の取得
まず、`Canvas`クラスにマウス座標を保存しておく変数と、マウスが動いた時に呼ばれる`mouseMoved`関数を作成します。

```javascript:00_empty/Canvas/index.js
// ~ 省略 ~
import { Vector2 } from 'three/src/math/Vector2';

export default class Canvas {
  constructor() {
    // マウス座標
    this.mouse = new Vector2(0, 0);
    
    // ~ 省略 ~
  }

  // ~ 省略 ~

  mouseMoved(x, y) {
    this.mouse.x =  x - (this.w / 2);// 原点を中心に持ってくる
    this.mouse.y = -y + (this.h / 2);// 軸を反転して原点を中心に持ってくる
  }
};
```

次に、`Page00`クラスで`window`の`mousemove`イベントにイベントリスナーを登録し、その中で↑で作った`canvas.mouseMoved`を呼びます。
`mousemove`イベントから取得できる値（`e.clientX`と`e.clientY`）が`window`座標なので、`canvas.mouseMoved`関数の中で`WebGL`座標に変換する必要があるというわけです。

```javascript:00_empty/index.js
import Canvas from './Canvas';

export default class Page00 {
  constructor() {
    const canvas = new Canvas();

    window.addEventListener('mousemove', e => {
      canvas.mouseMoved(e.clientX, e.clientY);
    });
  }
};
```

## マウス座標でライトを動かす
まず、ライト座標の初期値の`xy`を`0`にしておきます。

```javascript
this.light.position.set(0, 0, 400);// ライトの位置を設定
```
`mouseMoved`関数内で、マウス座標が更新された後にライトの座標にマウス座標をそのまま代入します。

```javascript
mouseMoved(x, y) {
  this.mouse.x =  x - (this.w / 2);// 原点を中心に持ってくる
  this.mouse.y = -y + (this.h / 2);// 軸を反転して原点を中心に持ってくる

  // ライトの xy座標 をマウス位置にする
  this.light.position.x = this.mouse.x;
  this.light.position.y = this.mouse.y;
}
```

マウスにライトが付いてくるようになりました！
![Kapture 2018-12-13 at 18.27.57.gif](https://qiita-image-store.s3.amazonaws.com/0/86966/019ac937-9d85-4aed-f513-8bd21120b507.gif)

3DCGが勝手に動いているだけでなく、自分の操作に反応するだけでちょっと楽しいですね。


# スクロールでのインタラクション

## スクロール量の取得
まず、スクロールさせるために`public/00_empty/index.html`にスクロール用のコンテナを追加します。

```html:public/00_empty/index.html
<body>
  <!-- この中にcanvasが入ります -->
  <div id="canvas-container"></div>

  <!-- スクロール用コンテナ -->
  <div id="scroll-container"></div>
  
  <script src="/resource/js/vendor.bundle.js"></script>
  <script src="/resource/js/common.bundle.js"></script>
</body>
```

スクロールコンテナ用のスタイルはすでに`public/resource/css/common.css`に記述済みなので、追加する必要はありません。対応する箇所は以下になります。

```css:public/resource/css/common.css
#scroll-container {
  position: relative;
  width: 100%;
  height: 200%;
}
```
`Canvas`クラスには現在のスクロール量を保存しておく変数と、スクロールしたときに呼ばれる`scrolled`関数を用意しておきます。

```javascript:00_empty/Canvas/index.js
// ~ 省略 ~

export default class Canvas {
  constructor() {
    // スクロール量
    this.scrollY = 0;

    // ~ 省略 ~
  }

  // ~ 省略 ~

  scrolled(y) {
    this.scrollY = y;
  }
};
```
そして、`Page00`クラスでウィンドウの`scroll`イベントで↑で作った`canvas.scrolled`関数を呼び出します。
ページを表示したときにすでに前回のスクロール位置まで移動しているケースがあるので、イベント発生時だけでなく、ページ表示時にも`canvas.scrolled`関数を呼んでスクロール位置を更新しておくことを忘れないようにしましょう。

```javascript:00_empty/index.js
import Canvas from './Canvas';

export default class Page00 {
  constructor() {
    const canvas = new Canvas();
    
    canvas.scrolled(window.scrollY);
    
    window.addEventListener('mousemove', e => {
      canvas.mouseMoved(e.clientX, e.clientY);
    });
    
    window.addEventListener('scroll', e => {
      canvas.scrolled(window.scrollY);
    });
  }
};
```

## スクロール量でメッシュを動かす
`Canvas`クラスの`render`関数内で`this.mesh.position.y`にスクロール量を代入してメッシュの位置を更新します。

```javascript:00_empty/Canvas/index.js
render() {
  // ~ 省略 ~

  // スクロールに追従させる
  this.mesh.position.y = this.scrollY;
  
  // 画面に表示
  this.renderer.render(this.scene, this.camera);
}
```


スクロールに追従するようになりました！
![Kapture 2018-12-17 at 12.17.40.gif](https://qiita-image-store.s3.amazonaws.com/0/86966/12907132-c06f-11bf-e40a-783ec3400d7f.gif)
ちなみに、ここで`this.mesh.position.y`に反映させるスクロール量を増やしたり減らしたりすることで簡単にパララックス的な動きを作ることができます。

DOM要素がないとパララックスがわかりにくいので、先に`index.html`に`h1`を追加しておきます。

```html:index.html
<!-- スクロール用コンテナ -->
<div id="scroll-container">
  <h1 id="scroll-container_title">THREEJS<br>TUTORIAL<br>03</h1>
</div>
```

こちらもスタイルは`common.css`に記述済みです。

```css:common.css
#scroll-container_title {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);

  color: #fff;
  font-size: 7em;
  letter-spacing: 0.05em;
  font-family: sans-serif;
  font-style: italic;
  font-weight: bold;
}
```

```javascript
// スクロール量より多く動く
this.mesh.position.y = this.scrollY * 1.5;
```
![Kapture 2018-12-17 at 12.56.34.gif](https://qiita-image-store.s3.amazonaws.com/0/86966/4b51cda0-df31-f989-e83d-b1fcc0a56f72.gif)

```javascript
// スクロール量より少なく動く
this.mesh.position.y = this.scrollY * 0.5;
```

![Kapture 2018-12-17 at 12.57.08.gif](https://qiita-image-store.s3.amazonaws.com/0/86966/d4484dd2-6e1b-e99d-e46c-b78d5ade2019.gif)

今回はマウス座標やスクロール量をそのままライトの`xy`座標とメッシュの`y`座標に反映させましたが、サイズや回転などに入れてみてもおもしろいとおもいます。