様々なシーンで活用できるプレイヤー追従型多機能カメラスクリプト

# はじめに

Cinemachineを意地でも使わない[yoship1639](https://twitter.com/yoship1639)です。

対象のオブジェクトを舐めまわすように回転・追従する多機能カメラコントローラを作ってみたのでその紹介です。
SceneのMain Cameraにアタッチし追従対象のTransformを指定するだけでいいのでお手軽で、また調整すればプレイヤーの追従にもそのまま使えるので様々なシーンで使えるのではないかと思います。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">キャラクタをアタッチするだけで使える多機能カメラスクリプトを作ってみた。いろんな場面で使えるように中心回転、遅延、フリールック、距離、高さ、手ブレ、ドリーズームを搭載しました。プレイヤー追従にもそのまま使えます。<br>qiita記事書いて配布予定です(*&#39;ω&#39;*) <a href="https://t.co/SSYJBybaJX">pic.twitter.com/SSYJBybaJX</a></p>&mdash; yoship@東方ACT開発中 (@yoship1639) <a href="https://twitter.com/yoship1639/status/1190978337239269376?ref_src=twsrc%5Etfw">November 3, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

どの様な機能があるのかご紹介します。

# カメラの機能

今回作成したカメラスクリプトは標準入力からの操作でも他スクリプトからでも扱える様にしてあります。

![039.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113595/d2bdd106-45ae-7f0f-7903-989f1334b21f.png)


### Target Rotation

ターゲットを中心に周ります。基本機能です。
マウス左＋移動です。

### Following

ターゲットを中心にしているのでそのまま追従します。

![006.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113595/b7e8dea2-23e0-512c-71b7-90386e3c5ad5.gif)


### Damping

遅延です。Dampingの値を大きくするとカメラが遅延して追従します。
滑らかに見せるには必須の手法です。

![001.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113595/1d66ab83-652e-4a76-3ae6-84b49c07095b.gif)


### Free Look

ターゲットを中心としてその周りを見渡すことができます。
特定の場所を見たい場合に使えます。
マウス右＋移動です。マウス中央で初期化します。

![002.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113595/222a1417-9b1b-c393-8e60-c988568dd0c6.gif)


### Distance

ターゲットとの距離を変える事が出来ます。これもなくてはならない基本機能ですね。
マウスホイールです。

### Look Height

一々プレイヤーの目の位置にトランスフォーム用のゲームオブジェクトを設定するのが面倒な人のための機能です。
見る高さを変えます。

### Noise

手ブレです。いかにも人が手で撮っている様に見せます。
あまり大きくすると酔います。

![003.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113595/6ab27600-58da-c855-66d6-d7f82fe7518d.gif)

### Dolly Zoom

めまいショットともいわれるドリーズーム。演出を凝るためにはこの効果を試してみるのもいいかもしれません。

![004.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113595/b7e93d85-9d3a-933e-3f97-3e9bcd3f8dae.gif)

### Vibration

振動です。縦振動のみ、横振動のみ等も設定できます。

![005.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/113595/5cdf7c58-36c4-9361-30fc-ff938bca05c6.gif)

### Wall Detection

カメラとキャラクタの間に遮蔽物がある場合に正しく処理する機能です。
壁裏にカメラがめり込むのを防ぎます。

### Fixed Point

有効にするとカメラの位置をその場から動かさないようにできます。

### Update Function

UpdateでシミュレートするかFixedUpdateでシミュレートするかを選べます。
移動対象が剛体の場合はFixedUpdateで追従するのが有効です。

# 各種アルゴリズム解説

それぞれの機能をどのように実現しているのかを簡単に解説したいと思います。

### Target Rotation + Following + Distance + Height

ターゲットを中心にカメラを回転する手法ですが、これは*Sin*と*Cos*をうまく組み合わせることで簡単に実現できます。
ターゲットを中心としたカメラの水平位置は、上から見て単位円を描けば簡単に理解できます。ターゲットを後ろから見た状態を基準にした場合単位円のX座標は*Sin(rot)*、Z座標は*-Cos(rot)*であることが分かるので、まずそれをそのままプログラムに落とし込みます。

```csharp
[SerializeField] public float rot = 0.0f; // 水平回転角度

var pos = Vector3.zero;
pos.x = Mathf.Sin(rot * Mathf.Deg2Rad);
pos.z = -Mathf.Cos(rot * Mathf.Deg2Rad);
```

次に、Y座標ですが、これは単純で*Sin(height)*です。そして、上下に回り込むほどX,Z座標は小さくなるので、*Cos(height)*を掛けてあげます。

```csharp
[SerializeField] public float rot = 0.0f; // 水平回転角度
[SerializeField] public float height = 0.0f; // 上下回り込み角度

var pos = Vector3.zero;
pos.x = Mathf.Sin(rot * Mathf.Deg2Rad) * Mathf.Cos(height * Mathf.Deg2Rad);
pos.z = -Mathf.Cos(rot * Mathf.Deg2Rad) * Mathf.Cos(height * Mathf.Deg2Rad);
pos.y = Mathf.Sin(height * Mathf.Deg2Rad);
```

単位円の長さは1なので、このままではターゲットからカメラまでの距離が1で固定されてしまうので、*distance*を掛けてあげます。また、中心をターゲットにするので、`var pos = Vector3.zero`は`var pos = target.position`に直してあげます。これでターゲットを中心にして回るという処理ができました。ターゲットを中心とするので、自動的にFollowing機能が付いてきます。更に、posに*Vector3.up * eyeHeight*を足してあげれば高さも調整できます。

```csharp
[SerializeField] public Transform target = null; // 追従ターゲット
[SerializeField] public float rot = 0.0f; // 水平回転角度
[SerializeField] public float height = 0.0f; // 上下回り込み角度
[SerializeField] public float distance = 10.0f; // カメラ距離
[SerializeField] public float eyeHeight = 1.0f; // ターゲットの視点の高さ

var pos = target.position + Vector3.up * eyeHeight;
pos.x += Mathf.Sin(rot * Mathf.Deg2Rad) * Mathf.Cos(height * Mathf.Deg2Rad) * distance;
pos.z += -Mathf.Cos(rot * Mathf.Deg2Rad) * Mathf.Cos(height * Mathf.Deg2Rad) * distance;
pos.y += Mathf.Sin(height * Mathf.Deg2Rad) * distance;

camera.transform.position = pos;
camera.transform.LookAt(target.position + Vector3.up * eyeHeight);
```

これで、4つの機能が出来上がりました。

### Damping

遅延は`Mathf.Lerp`を使えば大丈夫です。
先ほどの*rot*や*height*の値を遅延させればいいので、このような処理をかませます。

```csharp
[SerializeField] public float rotationDamping = 10.0f;
private float targetRot = 0.0f;

targetRot = Mathf.Lerp(targetRot, rot, Mathf.Clamp01(Time.deltaTime * 100.0f / rotationDamping));
```

`Mathf.Lerp`のrate値を時間にしてあげる事で、時間がたつにつれて*targetRot*は*rot*に近づくようになります。近づく速度は*rotationDamping*で調整可能です。この処理を*height*や*distance*にも適用させると動きが滑らかになります。

### Free Look

これは意外と簡単で、`camera.transform.LookAt`の後に`camera.transform.Rotate`で回転させればいいだけです。

```csharp
[SerializeField] public Vector3 freeLookRotation = Vector3.zero;

camera.transform.LookAt(target.position + Vector3.up * eyeHeight);
camera.transform.Rotate(freeLookRotation);
```

### Noise

手ブレは、パーリンノイズを使います。引数を経過時間にすればいいだけなので、これも簡単に作れてしまいます。
Free Lookと同じ様に`camera.transform.LookAt`の後に`camera.transform.Rotate`を行います。
Zだけ別の変数にしているのは、cameraのupの変動が大きいと見ずらくなってしまうので個別に調整できるようにするためです。

```csharp
[SerializeField] public float noise = 1.0f;
[SerializeField] public float noiseZ = 0.4f;
[SerializeField] public float noiseSpeed = 1.0f;

var rotNoise = Vector3.zero;
rotNoise.x = (Mathf.PerlinNoise(Time.time * noiseSpeed, 0.0f) - 0.5f) * noise;
rotNoise.y = (Mathf.PerlinNoise(Time.time * noiseSpeed, 0.4f) - 0.5f) * noise;
rotNoise.z = (Mathf.PerlinNoise(Time.time * noiseSpeed, 0.8f) - 0.5f) * noiseZ;
camera.transform.Rotate(rotNoise);
```

### Vibration

手ブレと要領で`camera.transform.Rotate`してあげます。
引数をランダムにすればいいだけです。

```csharp
[SerializeField] public Vector3 vibration = Vector3.zero;

var vib = Vector3.zero;
vib.x = new Vector3(Random.Range(-1.0f, 1.0f) * vibration.x;
vib.y = new Vector3(Random.Range(-1.0f, 1.0f) * vibration.y;
vib.z = new Vector3(Random.Range(-1.0f, 1.0f) * vibration.z;
camera.transform.Rotate(vib);
```

### Dolly Zoom

これはちょっと考えなければできない処理です。ドリーズームはカメラのField of ViewとDistanceをうまく調整する事でターゲットの大きさを変えずに周りの遠近感を変える手法です。

説明が面倒なので抜粋ソースコードを見て納得してください（投げやり）
最初の奴の*distance*を*dollyDist*に置き換えればおｋです。

```csharp
[SerializeField] public float dolly = 0.34f;

private float GetDollyDistance(float fov, float distance)
{
    return distance / (2.0f * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad));
}

private float GetDollyFoV(float dolly, float distance)
{
    return 2.0f * Mathf.Atan(distance * 0.5f / dolly) * Mathf.Rad2Deg;
}

var dollyFoV = GetDollyFoV(Mathf.Pow(1.0f / dolly, 2.0f), distance);
var dollyDist = GetDollyDistance(dollyFoV, distance);
camera.fieldOfView = dollyFoV;
```

一応Unity公式リファレンスにも説明があります。
[https://docs.unity3d.com/jp/460/Manual/DollyZoom.html](https://docs.unity3d.com/jp/460/Manual/DollyZoom.html)

### Wall Detection

壁を検知し壁に埋まらない様にする手法です。ターゲットからカメラ方向に*Ray*を飛ばして壁があったら*distance*をその壁までの距離に置き換えます。

```csharp
[SerializeField] public float wallDetectionDistance = 0.3f;

RaycastHit hit;
var dir = (target.position - camera.transform.position).normalized;
if (Physics.SphereCast(pos, wallDetectionDistance, dir, out hit, dollyDist))
{
    dollyDist = hit.distance;
}
```

これだけで壁にめり込まなくなります。

残りのFixed PointとUpdate Functionは特に説明することはないので割愛です。

# ソースコード

前章のアルゴリズムをごった煮して入力を受け付ける様にしたら多機能カメラコントローラの完成です。
コピペしてメインカメラに追加しターゲットを設定すればそのまま使えます。

```csharp:MultifunctionFollowingCamera.cs
using UnityEngine;

public class MultifunctionFollowingCamera : MonoBehaviour
{
    [SerializeField] public Transform target;
    [SerializeField] public bool enableInput = true;
    [SerializeField] public bool simulateFixedUpdate = false;
    [SerializeField] public bool enableDollyZoom = true;
    [SerializeField] public bool enableWallDetection = true;
    [SerializeField] public bool enableFixedPoint = false;
    [SerializeField] public float inputSpeed = 4.0f;
    [SerializeField] public Vector3 freeLookRotation;
    [SerializeField] public float height;
    [SerializeField] public float distance = 8.0f;
    [SerializeField] public Vector3 rotation;
    [SerializeField] [Range(0.01f, 100.0f)] public float positionDamping = 16.0f;
    [SerializeField] [Range(0.01f, 100.0f)] public float rotationDamping = 16.0f;
    [SerializeField] [Range(0.1f, 0.99f)] public float dolly = 0.34f;
    [SerializeField] public float noise = 0.0f;
    [SerializeField] public float noiseZ = 0.0f;
    [SerializeField] public float noiseSpeed = 1.0f;
    [SerializeField] public Vector3 vibration = Vector3.zero;
    [SerializeField] public float wallDetectionDistance = 0.3f;
    [SerializeField] public LayerMask wallDetectionMask = 1;

    private Camera cam;
    private float targetDistance;
    private Vector3 targetPosition;
    private Vector3 targetRotation;
    private Vector3 targetFree;
    private float targetHeight;
    private float targetDolly;

    void Start()
    {
        cam = GetComponent<Camera>();

        targetDistance = distance;
        targetRotation = rotation;
        targetFree = freeLookRotation;
        targetHeight = height;
        targetDolly = dolly;

        var dollyDist = targetDistance;
        if (enableDollyZoom)
        {
            var dollyFoV = GetDollyFoV(Mathf.Pow(1.0f / targetDolly, 2.0f), targetDistance);
            dollyDist = GetDollyDistance(dollyFoV, targetDistance);
            cam.fieldOfView = dollyFoV;
        }
        if (target == null) return;
        var pos = target.position + Vector3.up * targetHeight;
        var offset = Vector3.zero;
        offset.x += Mathf.Sin(targetRotation.y * Mathf.Deg2Rad) * Mathf.Cos(targetRotation.x * Mathf.Deg2Rad) * dollyDist;
        offset.z += -Mathf.Cos(targetRotation.y * Mathf.Deg2Rad) * Mathf.Cos(targetRotation.x * Mathf.Deg2Rad) * dollyDist;
        offset.y += Mathf.Sin(targetRotation.x * Mathf.Deg2Rad) * dollyDist;
        targetPosition = pos + offset;
    }

    void Update()
    {
        if (!simulateFixedUpdate) Simulate(Time.deltaTime);
    }

    void FixedUpdate()
    {
        if (simulateFixedUpdate) Simulate(Time.fixedDeltaTime);
    }

    private void Simulate(float deltaTime)
    {
        if (enableInput)
        {
            if (Input.GetKey(KeyCode.LeftAlt))
            {
                dolly += Input.GetAxis("Mouse ScrollWheel") * 0.2f;
                dolly = Mathf.Clamp(dolly, 0.1f, 0.99f);
            }
            else
            {
                distance *= 1.0f - Input.GetAxis("Mouse ScrollWheel");
                distance = Mathf.Clamp(distance, 0.01f, 1000.0f);
            }

            if (Input.GetMouseButton(0))
            {
                rotation.x -= Input.GetAxis("Mouse Y") * inputSpeed;
                rotation.x = Mathf.Clamp(rotation.x, -89.9f, 89.9f);
                rotation.y -= Input.GetAxis("Mouse X") * inputSpeed;
            }
            if (Input.GetMouseButton(1))
            {
                freeLookRotation.x -= Input.GetAxis("Mouse Y") * inputSpeed * 0.2f;
                freeLookRotation.y += Input.GetAxis("Mouse X") * inputSpeed * 0.2f;
            }
            if (Input.GetMouseButtonDown(2))
            {
                freeLookRotation = Vector3.zero;
            }
        }

        var posDampRate = Mathf.Clamp01(deltaTime * 100.0f / positionDamping);
        var rotDampRate = Mathf.Clamp01(deltaTime * 100.0f / rotationDamping);

        targetDistance = Mathf.Lerp(targetDistance, distance, posDampRate);
        targetRotation = Vector3.Lerp(targetRotation, rotation, rotDampRate);
        targetFree = Vector3.Lerp(targetFree, freeLookRotation, rotDampRate);
        targetHeight = Mathf.Lerp(targetHeight, height, posDampRate);
        targetDolly = Mathf.Lerp(targetDolly, dolly, posDampRate);

        if (Mathf.Abs(targetDolly - dolly) > 0.005f)
        {
            targetDistance = distance;
        }

        var dollyDist = targetDistance;
        if (enableDollyZoom)
        {
            var dollyFoV = GetDollyFoV(Mathf.Pow(1.0f / targetDolly, 2.0f), targetDistance);
            dollyDist = GetDollyDistance(dollyFoV, targetDistance);
            cam.fieldOfView = dollyFoV;
        }

        if (target == null) return;

        var pos = target.position + Vector3.up * targetHeight;

        if (enableWallDetection)
        {
            RaycastHit hit;
            var dir = (targetPosition - pos).normalized;
            if (Physics.SphereCast(pos, wallDetectionDistance, dir, out hit, dollyDist, wallDetectionMask))
            {
                dollyDist = hit.distance;
            }
        }

        var offset = Vector3.zero;
        offset.x += Mathf.Sin(targetRotation.y * Mathf.Deg2Rad) * Mathf.Cos(targetRotation.x * Mathf.Deg2Rad) * dollyDist;
        offset.z += -Mathf.Cos(targetRotation.y * Mathf.Deg2Rad) * Mathf.Cos(targetRotation.x * Mathf.Deg2Rad) * dollyDist;
        offset.y += Mathf.Sin(targetRotation.x * Mathf.Deg2Rad) * dollyDist;

        if (Mathf.Abs(targetDolly - dolly) > 0.005f)
        {
            targetPosition = offset + pos;
        }
        else
        {
            targetPosition = Vector3.Lerp(targetPosition, offset + pos, posDampRate);
        }

        if (!enableFixedPoint) cam.transform.position = targetPosition;
        cam.transform.LookAt(pos, Quaternion.Euler(0.0f, 0.0f, targetRotation.z) * Vector3.up);
        cam.transform.Rotate(targetFree);

        if (noise > 0.0f || noiseZ > 0.0f)
        {
            var rotNoise = Vector3.zero;
            rotNoise.x = (Mathf.PerlinNoise(Time.time * noiseSpeed, 0.0f) - 0.5f) * noise;
            rotNoise.y = (Mathf.PerlinNoise(Time.time * noiseSpeed, 0.4f) - 0.5f) * noise;
            rotNoise.z = (Mathf.PerlinNoise(Time.time * noiseSpeed, 0.8f) - 0.5f) * noiseZ;
            cam.transform.Rotate(rotNoise);
        }

        if (vibration.sqrMagnitude > 0.0f)
        {
            cam.transform.Rotate(new Vector3(Random.Range(-1.0f, 1.0f) * vibration.x, Random.Range(-1.0f, 1.0f) * vibration.y, Random.Range(-1.0f, 1.0f) * vibration.z));
        }
    }

    private float GetDollyDistance(float fov, float distance)
    {
        return distance / (2.0f * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad));
    }

    private float GetFrustomHeight(float distance, float fov)
    {
        return 2.0f * distance * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad);
    }

    private float GetDollyFoV(float dolly, float distance)
    {
        return 2.0f * Mathf.Atan(distance * 0.5f / dolly) * Mathf.Rad2Deg;
    }
}
```

# おわりに

以前もカメラワークに関する記事を書かせていただきました。
[ゲームの質を劇的に上げるカメラワークの3つの手法解説【減衰・FoV・手ブレ】](https://qiita.com/yoship1639/items/9bf6f8ad080b3c496b12)

3D関連は**カメラワークを変えるだけで見た目が大きく変わる**のでぜひ参考にしていただければと思います。

この機能が足りない、この挙動がおかしい等ありましたら遠慮なくコメントください！修正バージョンを記載します。
よきUnityライフを。

※本記事で使用しているモデルは以下のライセンスで提供されています。
© Unity Technologies Japan/UCL