three.js オブジェクトがカメラに収まるように奥行きを自動調整させる

# 概要
three.jsに限らずですが、何らかの3Dオブジェクトをカメラにちょうど収めたいという場面があると思います。固定アスペクト比なら手動で調節しても良いですが、アスペクト比が変動する場合、物体がはみ出したり小さすぎたりしてしっくりこない場合があります。
なので、いい感じにオブジェクトが収まるようにカメラの奥行きを自動調節するものをthree.jsで実装してみました。

## デモ
以下が今回作ったデモです。
画面をドラッグすると物体が回転しますが、ちょうど直方体全体がカメラに収まるように調整されているのがわかると思います。

https://arihide.github.io/demos/fit_camera/
[![camera-fit.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/323576/d7516247-00ef-1452-fb87-1e9f154e9799.gif)](https://arihide.github.io/demos/fit_camera/)

ソースコードはこちら
https://github.com/Arihide/demos/tree/master/fit_camera

# 解説
![camera-fit.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/323576/e71d17fa-9e10-1204-62c9-1e203d118457.png)
やりたいことを改めて説明すると、上の画像のように現在のカメラ位置ではスクリーンに収まらない場合に、カメラの奥行きを調節することで収まるようにします。

これを2つの手順で実現します。
1. スクリーンの中心から一番遠い点を求める
2. スクリーンから一番遠い点が画面内に収まるように奥行きを調整する

### 1. スクリーンの中心から一番遠い点を求める
物体すべてをカメラに収めるとは、言い換えると「その物体の中で一番スクリーンから遠い頂点を画面に収める」のと等しいです。
なので、ここではその頂点を求めたいワケですが、今回は泥臭く全ての頂点を1つ1つ射影して、どの点が最も遠いか求めています。

```javascript
        { // 1. スクリーン座標の中心からみて最も遠い点を求める
            let max = -Infinity, maxIndex = 0
            let point = new THREE.Vector3()

            for (let i = 0; i < 8; i++) {

                point.set(
                    i & 0b100 ? this._targetBox.max.x : this._targetBox.min.x,
                    i & 0b010 ? this._targetBox.max.y : this._targetBox.min.y,
                    i & 0b001 ? this._targetBox.max.z : this._targetBox.min.z,
                ).sub(this._targetCenter).project(this.camera)

                let pointMax = Math.max(point.x, point.y, -point.x, -point.y)

                if (pointMax >= max) {
                    max = pointMax
                    maxIndex = i
                }
            }

            farthestPoint.set(
                maxIndex & 0b100 ? this._targetBox.max.x : this._targetBox.min.x,
                maxIndex & 0b010 ? this._targetBox.max.y : this._targetBox.min.y,
                maxIndex & 0b001 ? this._targetBox.max.z : this._targetBox.min.z,
            )
        }
```

### 2.スクリーンから一番遠い点が画面内に収まるように奥行きを調整する

次に、先ほど求めた頂点をカメラに収める処理について説明しますが、説明のため視錐台をｙ軸方向から見た画像を載せます。
![camera-fit-explain.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/323576/789608ad-3ad1-123e-5345-256d50d09ffe.png)
ここで、
- 座標軸原点は現在のカメラ位置
- $\theta$ はカメラの画角
- $(z, x)$ は先ほど求めたスクリーンから一番遠い点
です。
図からもわかるように$(z, x)$は描画領域に収まっていない（=スクリーンの外にある）です。
なので、ちょうど収まるようなカメラと点の距離 $z_{target}$ を求めます。

正接の定義から以下が成り立ちます。

```math
\tan (\theta / 2) = \frac{x}{z_{target}}  
```
これを次のように式変形します。

```math
z_{target} = z \cdot \frac{x}{z \tan(\theta / 2)} 
```

ここで、$\frac{x}{z \tan(\theta / 2)} $は実はｘをパースペクティブ射影変換した物と等価です。
（詳しくは[完全ホワイトボックスなパースペクティブ射影変換行列](http://marupeke296.com/DXG_No70_perspective.html)を参照）
そこで、この射影変換をprojectと書くとすると、$z_{target}$ の導出式は次のように書けます。

```math
z_{target} = z \cdot project(x)
```
----
最後に
1. スクリーンの中心から一番遠い点を求める
2. スクリーンから一番遠い点が画面内に収まるように奥行きを調整する
の処理を実際に行っているソースコードを掲載します。
([CameraFitter.js](https://github.com/Arihide/demos/blob/master/fit_camera/CameraFitter.js)のfitCamera()関数部分)

```JavaScript
        const targetToCamera = new THREE.Vector3()
        const farthestPoint = new THREE.Vector3() // スクリーン座標中心から最も遠い点

        this.camera.lookAt(this._targetCenter)

        { // 1. スクリーン座標の中心からみて最も遠い点を求める
            let max = -Infinity, maxIndex = 0
            let point = new THREE.Vector3()

            for (let i = 0; i < 8; i++) {

                point.set(
                    i & 0b100 ? this._targetBox.max.x : this._targetBox.min.x,
                    i & 0b010 ? this._targetBox.max.y : this._targetBox.min.y,
                    i & 0b001 ? this._targetBox.max.z : this._targetBox.min.z,
                ).sub(this._targetCenter).project(this.camera)

                let pointMax = Math.max(point.x, point.y, -point.x, -point.y)

                if (pointMax >= max) {
                    max = pointMax
                    maxIndex = i
                }
            }

            farthestPoint.set(
                maxIndex & 0b100 ? this._targetBox.max.x : this._targetBox.min.x,
                maxIndex & 0b010 ? this._targetBox.max.y : this._targetBox.min.y,
                maxIndex & 0b001 ? this._targetBox.max.z : this._targetBox.min.z,
            )
        }

        // 2.スクリーンから一番遠い点が画面内に収まるように奥行きを調整する
        // 直方体からカメラに向かう単位ベクトル
        targetToCamera.subVectors(this.camera.position, this._targetCenter).normalize()

        // 直方体→カメラベクトルに射影
        const farthestPointProjected = new THREE.Vector3()
        farthestPointProjected.copy(farthestPoint).projectOnVector(targetToCamera)

        farthestPoint
            .sub(farthestPointProjected) // 原点を通るスクリーンに並行な面に移動する
            .project(this.camera)

        const scale = Math.max(farthestPoint.x, farthestPoint.y, -farthestPoint.x, -farthestPoint.y)

        // 結果を格納
        this.spherical.setFromCartesianCoords(
            this.camera.position.x - this._targetCenter.x,
            this.camera.position.y - this._targetCenter.y,
            this.camera.position.z - this._targetCenter.z,
        )
        this.spherical.radius = (this.spherical.radius * scale) + farthestPointProjected.length()

        // 球座標から元に戻す
        this.camera.position
            .setFromSpherical(this.spherical)
            .add(this._targetCenter)
```