Orbital Cameraの独自実装の手法について

「[QualiArts Advent Calendar 2022](https://qiita.com/advent-calendar/2022/qualiarts)」 4日目の記事になります。
株式会社QualiArtsでUnityエンジニアをしています片岡です。
今回はUnityで独自のOrbitalなCamera（以降、Orbital Camera）を実装する方法について紹介していきます。独自に実装することで以下のGifのように3Dモデルを観察するのに特化したOrbital Cameraも実現できます。このOrbital Cameraの仕組みは弊社のゲームアプリ「[IDOLY PRIDE](https://idolypride.jp/game/)」や社内ツールにて実際に導入されています。本記事ではGifのものと異なる、簡易機能版のご紹介となります。
![Kapture 2022-12-03 at 21.50.12.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878088/5b23a1bd-2754-fc78-7356-daad750b3846.gif)
なお、[Cinemachine](https://unity.com/ja/unity/features/editor/art-and-design/cinemachine)のVirtual Cameraの利用を前提とします。
コアラについて気になる方は[昨年のアドベントカレンダーの私の記事](https://qiita.com/vinyel/items/dea20d3e8bf54d8470c7)をぜひご覧ください！

## はじめに
UnityでのOrbital Cameraの実現はCinemachineのVirtual CameraにOrbital Transposerという備え付けの機能があるので、それを利用すると簡単です。しかし、備え付けであるため追加で色々なことをしようとすると独自で実装してしまった方が良かったりするかもしれません。今回はその一手法をご紹介します。

## OrbitalCameraとは
Orbital Cameraは下図で示すように、ある軌道に沿ってぐるぐると操作できるカメラのことを指します。
例としては、キャラを俯瞰しながら操作するTPS(Third Person Shooting)ゲームがありますね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878088/23474465-c65f-78b3-c415-83737df8eb99.png)

### CinemachineのOrbital Transposer
先述したようにUnityでは、CinemachineにOrbital Transposerという機能があります。とても簡単なのでこの機能を利用したOrbital Cameraの実現手順を述べておきます。

1. UnityのScene上にお好きな3Dモデルを配置し、VirtualCameraを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878088/ca318034-1e65-4e3a-8ab9-bef94fad8731.png)
1. CinemachineVirtualCameraコンポーネントの"Follow"と"LookAt"に3Dモデルを指定し、"Body"をOrbitalTransposerにします。以上です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878088/96418d61-9880-f3fc-f4db-579c815eaa80.png)
以下のように、ぐるぐるできるようになります。
![Kapture 2022-12-03 at 23.04.38.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878088/9a0cf34b-934f-d649-a19c-964362e47f94.gif)
また、Orbital Transposerには様々なパラメーターが用意されており、これらを調整することでカメラの挙動を変更することができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878088/bf2a1066-24af-8365-5f3e-fdd3667e9c53.png)
少し分かりづらいですが、Scene上の赤線に沿ってぐるぐるします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878088/a8ca65a9-d3c0-8412-1af5-920b9abfd2aa.png)

## 独自実装の手法
さて、Orbital Cameraの概要を述べましたが、ここからは独自実装の手法についてになります。結論から述べますと、[CinemachineExtension](https://docs.unity3d.com/Packages/com.unity.cinemachine@2.1/api/Cinemachine.CinemachineExtension.html)を利用した実装となります。具体的にはCinemachineExtensionを継承した独自クラスを用意し、abstructなメソッドである```PostPipelineStageCallback()```を実装してカメラの移動や回転を反映していきます。```PostPipelineStageCallback()```はカメラ処理時に毎回呼ばれる関数となるため、主に引数のCameraStateを書き換えることでカメラの移動や回転を反映できます。
```cs
    public class OrbitalTest : CinemachineExtension
    {
        protected override void PostPipelineStageCallback(CinemachineVirtualCameraBase vcam, CinemachineCore.Stage stage, ref CameraState state, float deltaTime)
        {
            // posとrotateを処理して位置と回転を反映
            state.RawPosition = pos;
            state.RawOrientation = rotate;
        }
    }
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878088/08256948-9b8c-b829-bac6-9aee7c2c2d04.png)
Virtual Cameraコンポーネントの"AddExtension"から独自クラスを適用できるようになります。
今回は**縦横回転**と**ズーム**に対応したOrbital Cameraを実装してみます。

### 縦横回転に対応してみる
キーボードの矢印キー操作による縦横回転に対応してみます。

```cs
using Cinemachine;
using UnityEngine;

public class OrbitalTest : CinemachineExtension
{
    private const float RotateSpeed = 2f;

    private Vector2 _currentRotate;
    private Transform _lookAt;

    private CinemachineVirtualCamera _virtualCamera;

    private void Update()
    {
        // 簡易的に操作を受け付けてみる
        if (Input.GetKey(KeyCode.LeftArrow)) Rotation(new Vector2(1f, 0f));

        if (Input.GetKey(KeyCode.RightArrow)) Rotation(new Vector2(-1f, 0f));

        if (Input.GetKey(KeyCode.UpArrow)) Rotation(new Vector2(0f, 1f));

        if (Input.GetKey(KeyCode.DownArrow)) Rotation(new Vector2(0f, -1f));

        // 左右上下回転操作
        void Rotation(Vector2 diffDelta)
        {
            diffDelta *= RotateSpeed * Time.deltaTime * 30;
            _currentRotate.x += diffDelta.y;
            _currentRotate.y += diffDelta.x;
        }
    }

    protected override void ConnectToVcam(bool connect)
    {
        base.ConnectToVcam(connect);
        if (connect == false) return;

        // 初期化処理
        // VirtualCameraのLookAt対象を生成しておく。わざわざ生成しなくても直接対象物を設定も可。
        _lookAt = GameObject.Find("LookAt")?.transform;
        if (_lookAt == null)
        {
            _lookAt = new GameObject("LookAt").transform;
            _lookAt.position = new Vector3(0f, 0.5f, 0f);
        }

        _virtualCamera = VirtualCamera as CinemachineVirtualCamera;
        _virtualCamera.LookAt = _lookAt;
    }

    protected override void PostPipelineStageCallback(CinemachineVirtualCameraBase vcam,
        CinemachineCore.Stage stage, ref CameraState state, float deltaTime)
    {
        var (pos, rotate) = GetTransform(_virtualCamera.LookAt.localToWorldMatrix);
        state.RawPosition = pos;
        state.RawOrientation = rotate;
    }

    /// <summary>
    /// カメラのTransformを取得
    /// </summary>
    private (Vector3 pos, Quaternion rotate) GetTransform(Matrix4x4 baseCenterMatrix)
    {
        // 中心点を回転させる
        var centerMatrix = baseCenterMatrix * Matrix4x4.Rotate(Quaternion.AngleAxis(_currentRotate.y, Vector3.up) *
                                                               Quaternion.AngleAxis(_currentRotate.x, Vector3.right));

        // 回転に基づいたカメラの位置を取得
        var distanceVector = new Vector3(0f, 0f, -3f);
        var pos = centerMatrix.MultiplyPoint3x4(distanceVector);

        return (pos, centerMatrix.rotation);
    }
}
```
上記のように記述します。```PostPipelineStageCallback()```内でカメラのTransformに関わるデータを取得するメソッド```GetTransform()```を呼び出し、```GetTransform()```は```_cuttentRotate```に基づいた**回転**、```distanceVector```(カメラとLookAtの距離)と先に計算した回転に基づいた**位置**を返します。返した値を```RawPosition```と```RawOrientation```にセットします。```_currentRotate```は```Update()```内のキー操作で更新されます。また、お察しの方もいらっしゃるかもしれませんが、後ほどズームの実装も行うため、```distanceVector```は一旦ローカル変数で置いています。

Unityエディターに戻ると以下のように「LookAt」という名前のオブジェクトが生成され、CinemachineVirtualCameraコンポーネントの"LookAt"が「LookAt」となります。こちらはソースコード内のコメントにあるように、対象のゲームオブジェクトが存在しうる場合は、最初からそのゲームオブジェクトをLookAtに設定しても構いません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878088/e537ab75-b3ea-59e6-e741-0376a86ff658.png)
![Kapture 2022-12-04 at 02.36.50.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878088/2f34565b-9fe0-81fb-0197-7f5d4fab1c34.gif)
以上で縦横回転に対応したOrbital Cameraが実装できました。

### ズームに対応してみる
縦横回転に対応してしまえば簡単です。```GetTransform()```にあったローカル変数```distanceVector```をフィールド変数```_distanceVector```に変更し、縦横回転の対応と同じくキー操作による更新対象となるフィールド変数```_currentDistance```を用意します。
```cs
    private const float RotateSpeed = 2f;

    private Vector2 _currentRotate;
    private Vector3 _distanceVector;
    private float _currentDistance = 3f;
    private Transform _lookAt;

    private CinemachineVirtualCamera _virtualCamera;

    private void Update()
    {
        // 簡易的に操作を受け付けてみる
        if (Input.GetKey(KeyCode.LeftArrow)) Rotation(new Vector2(1f, 0f));

        if (Input.GetKey(KeyCode.RightArrow)) Rotation(new Vector2(-1f, 0f));

        if (Input.GetKey(KeyCode.UpArrow)) Rotation(new Vector2(0f, 1f));

        if (Input.GetKey(KeyCode.DownArrow)) Rotation(new Vector2(0f, -1f));

        if (Input.GetKey(KeyCode.W)) Scale(-0.1f);

        if (Input.GetKey(KeyCode.S)) Scale(0.1f);

        // 左右上下回転操作
        void Rotation(Vector2 diffDelta)
        {
            diffDelta *= RotateSpeed * Time.deltaTime * 30;
            _currentRotate.x += diffDelta.y;
            _currentRotate.y += diffDelta.x;
        }

        // ズーム
        void Scale(float delta)
        {
            _currentDistance += delta;
        }
    }
```

```cs
    /// <summary>
    /// カメラのTransformを取得
    /// </summary>
    private (Vector3 pos, Quaternion rotate) GetTransform(Matrix4x4 baseCenterMatrix)
    {
        // 中心点を回転させる
        var centerMatrix = baseCenterMatrix * Matrix4x4.Rotate(Quaternion.AngleAxis(_currentRotate.y, Vector3.up) *
                                                               Quaternion.AngleAxis(_currentRotate.x, Vector3.right));

        // 回転に基づいたカメラの位置を取得
        _distanceVector.z = -_currentDistance; // -にしておく（カメラの角度と初期位置的による）
        var pos = centerMatrix.MultiplyPoint3x4(_distanceVector);

        return (pos, centerMatrix.rotation);
    }
```
以上でズームにも対応したOrbital Cameraが実装できました。
![Kapture 2022-12-04 at 03.41.22.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/878088/bd47e540-96c1-10a5-67d5-7c8ba408f3e2.gif)

## まとめ
本記事では、縦横回転とズームに対応した独自のOrbital Cameraの例を紹介させていただきました。実際に導入されているものはカメラの高さの調整や平行移動ができたり、当たり判定の処理、焦点距離の処理、スマホのドラッグ操作に対応、操作感の考慮などなどまだまだてんこ盛りです。いずれはこれらについてもご紹介したいなと思います。
最後まで読んでくださった方に向けてになりますが、「IDOLY PRIDE」の着替え機能が上記てんこ盛りのOrbital Cameraとなりますので、気になるという方はぜひアプリをインストールしてご確認いただけますと幸いです！

ありがとうございました！以上です！