three.js超入門 第4回 getBoundingClientRect()を使ったDOM要素との連携

# 概要
この記事では「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回 マウスやスクロールでのインタラクション](https://qiita.com/watabo_shi/items/0811d03390c18e46be86)
three.js超入門 第4回 DOM要素との連携
[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/0811d03390c18e46be86)はウィンドウのマウスやスクロールなどのイベントから取れる値を使って3Dオブジェクトをインタラクティブに動かしました。
今回はDOM要素の位置と大きさをthree.jsのオブジェクトに反映させて、3DオブジェクトをDOM要素の背景として利用してみます。

# 下準備
常に回転していると邪魔なので、まず回転に関する記述をコメントアウトしておきます。

```javascript
export default class Canvas {
  constructor() {
    // ~ 省略 ~

    // マテリアルを作成
    const mat = new MeshLambertMaterial({ color: 0xffffff });

    // ジオメトリとマテリアルからメッシュを作成
    this.mesh = new Mesh(geo, mat);
    // this.mesh.rotation.x = Math.PI / 4;
    // this.mesh.rotation.y = Math.PI / 4;

    // ~ 省略 ~
  }

  render() {
    // 次のフレームを要求
    requestAnimationFrame(() => { this.render(); });

    // ミリ秒から秒に変換
    // const sec = performance.now() / 1000;

    // 1秒で45°回転する
    // this.mesh.rotation.x = sec * (Math.PI / 4);
    // this.mesh.rotation.y = sec * (Math.PI / 4);

    // スクロールに追従させる
    // this.mesh.position.y = this.scrollY * 0.5;

    // 画面に表示
    this.renderer.render(this.scene, this.camera);
  }

  // ~ 省略 ~
};
```

# DOM要素のウィンドウ上の矩形を取得
`Canvas`クラス内で`DOM`要素を取得するために、コンストラクタで`id`を引数で取ってくるように変更します。

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

export default class Page00 {
  constructor() {
    const canvas = new Canvas('scroll-container_title');
    
    // ~ 省略 ~
  }
};
```

`getElementById()`で対象のDOM要素を取得したら、[element.getBoundingClientRect()](https://developer.mozilla.org/ja/docs/Web/API/Element/getBoundingClientRect)でその要素の現在のウィンドウ上の矩形（`DOMRect`）を取得します。

`element.getBoundingClientRect()`で取得できる値は、ウィンドウの絶対座標なので、スクロールをするたびに値が更新されます。（`css`でいうところの`position: fixed;`にしたときの座標をイメージするとわかりやすいかとおもいます。）

```javascript:00_empty/Canvas/index.js
export default class Canvas {
  constructor(elementId) {
    // elementIdのついたDOM要素を取得
    this.element = document.getElementById(elementId);
    const rect   = this.element.getBoundingClientRect();

    // ~ 省略 ~
  }
};
```

`rect`をコンソールにログ出しすると以下のようになっています。
![スクリーンショット 2018-12-17 16.07.17.png](https://qiita-image-store.s3.amazonaws.com/0/86966/803d8f0b-5465-4a54-9545-a7ced56c9dd3.png)
`x, y`と`top, left`には同じ値が入っていますが、`x, y`は使用できるブラウザが限られているので、`top, left`を使います。


# DOMRectのサイズをジオメトリに反映
`DOMRect`の幅と高さを`BoxGeometry`に反映させます。
`this.mesh.position.z = -depth / 2`とすることで、直方体の半分前に出ている奥行きを補正して、`z = 0`のところに面がくるように補正しています。

```javascript
// ~ 省略 ~

export default class Canvas {
  constructor(elementId) {
    // elementIdのついたDOM要素を取得
    this.element = document.getElementById(elementId);
    const rect   = this.element.getBoundingClientRect();

    // ~ 省略 ~

    // DOMRectサイズの直方体ジオメトリを作成(幅, 高さ, 奥行き)
    const depth = 300;
    const geo   = new BoxGeometry(rect.width, rect.height, depth);

    // ~ 省略 ~

    // ジオメトリとマテリアルからメッシュを作成
    this.mesh = new Mesh(geo, mat);
    this.mesh.position.z = -depth / 2;// 奥行きの半分前に出ているのを下げる

    // ~ 省略 ~
  }

  // ~ 省略 ~
};
```

最初の位置はまだずれていますが、スクロールして位置を合わせて検証ツールで見るとDOMとメッシュのサイズが一致していることがわかります。
![スクリーンショット 2018-12-17 16.23.41.png](https://qiita-image-store.s3.amazonaws.com/0/86966/de3bb79f-657d-895e-55f1-f4fff8fc837c.png)

# DOMRectの位置をメッシュに反映
メッシュの位置は中心指定で、`DOMRect`の位置は左上指定なので、`DOMRect`の中心座標を求めてウィンドウの中心との差分をオフセットさせることで位置合わせができます。


```javascript
// ~ 省略 ~

export default class Canvas {
  constructor(elementId) {
    // elementIdのついたDOM要素を取得
    this.element = document.getElementById(elementId);
    const rect   = this.element.getBoundingClientRect();

    // ~ 省略 ~

    // ジオメトリとマテリアルからメッシュを作成
    this.mesh = new Mesh(geo, mat);
    // this.mesh.position.z = -depth / 2;// 奥行きの半分前に出ているのを下げる（↓に統合）

    // ウィンドウ中心からDOMRect中心へのベクトルを求めてオフセットする
    const center = new Vector2(rect.x + rect.width / 2, rect.y + rect.height / 2);
    const diff   = new Vector2(center.x - this.w / 2, center.y - this.h / 2);
    this.mesh.position.set(diff.x, -diff.y, -depth / 2);

    // ~ 省略 ~
  }

  // ~ 省略 ~
};
```

これでサイズと位置がぴったり合いました。
![スクリーンショット 2018-12-17 16.41.37.png](https://qiita-image-store.s3.amazonaws.com/0/86966/004cdcfc-9966-23a3-1fbd-1ba17eb2f47a.png)

# DOMRectの位置、サイズを維持しつつスクロール追従させる
メッシュの位置を設定する部分にデフォルトのスクロール量（`this.scrollY`）を足して、オフセット値を変数に保存します。

```javascript
this.mesh.position.set(diff.x, -(diff.y + this.scrollY), -depth / 2);
this.offsetY = this.mesh.position.y;
```

`render`関数内でコメントアウトしていたスクロール追従の部分のコメントを外して、`this.offsetY`を足します。

```javascript
this.mesh.position.y = this.offsetY + this.scrollY;
```

![Kapture 2018-12-17 at 16.53.26.gif](https://qiita-image-store.s3.amazonaws.com/0/86966/961fc907-cac4-93fe-74c5-465e4b01fed2.gif)

これでDOM要素にぴったりついてくる3Dオブジェクトができました！
ライティングによる表現は`css`では実現が難しいので、ボックスではなく平面を置くだけでも普通のWebサイトとは違う、一風変わった表現ができるとおもいます。