Unityでのモバイル3Dアクションゲームのカメラワーク

この記事は、[KLab Engineer Advent Calendar 2023](https://qiita.com/advent-calendar/2023/klab) の2日目の記事です。
こんにちは。KLabでエンジニアをしている [@tsune2ne](https://qiita.com/tsune2ne) です。

モバイルゲームではカメラ操作をするのはかなり億劫ですよね
そこで自分なりに使いやすいカメラワークを作ったので紹介します

![ブログ用_回り込み.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/26964e26-6110-8750-4682-2383374b9ec7.gif)

## Cinemachineについて
Unityで複数のカメラを管理するコンポーネント
今までは複数のカメラをおいてON/OFFの管理が煩雑でしたがまるっと管理してくれます

https://unity.com/ja/unity/features/editor/art-and-design/cinemachine

Cinemachineの仮想カメラはBodyとAimを指定することで被写体に追従するカメラを作れます
- BodyはFollowを元に仮想カメラの位置を決める
- AimはLookAtを元に仮想カメラの方向を決める

デフォルトでいくつかのコンポーネントが用意されていて、いろんなシチュエーションのカメラワークに対応していますが、CinemachineComponentBaseを継承することで自作できます

仮想カメラのtransformを直接いじるとカメラ切替時のトランジションがうまく動かなくなります
（トランジションはFollow/LookAtに対して補間がかかる）
しっかり作りたい場合は外部からtransformをいじるよりもCinemachineComponentを実装するのがオススメです

##  3人称視点カメラを自前で作る
### なぜ作るか
- デフォルトの3rd Person Followは細かい設定ができない
  - 移動速度や位置補正など、自由にやりたかった
  - 他人と話すときに角度ベースのほうが話しやすい

![ブログ用_カメラ操作.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/5985dddf-22e6-85ce-dd63-7d2b45be3455.gif)


### 実装
- 上下角と水平角による位置計算を行う
  - いろいろ悩んだけど結局これが一番わかりやすかった
  - 非エンジニアともコミュニケーションが取りやすい
- 位置管理なのでCinemachineComponentのBodyで記述

```C#
    [Header("基本設定")]
    [SerializeField, Tooltip("自機からみたカメラの位置角度。右巻きが正値。")]
    float cameraDistance = 5f;
    [SerializeField, Tooltip("自機からみたカメラの位置角度。右巻きが正値。")]
    public float horizontalAngle = 0f;
    [SerializeField, Tooltip("カメラの自機に対する高さオフセット。上向きが正値。")]
    public float verticalAngle = 0f;

    public override void MutateCameraState(ref CameraState curState, float deltaTime)
    {
        var basePos = FollowTargetPosition;
        var localPos = CalculateCameraPositionByAngle(horizontalAngle, verticalAngle);
        var newPos = basePos + localPos * cameraDistance;
        
        curState.RawPosition = newPos;
    }

    static Vector3 CalculateCameraPositionByAngle(float xAngle, float yAngle)
    {
        // 設定角度からカメラ座標を計算
        var xRadian = -(180 + xAngle % 360) * Mathf.PI / 180;
        var yRadian = yAngle % 360 * Mathf.PI / 180;
        return new Vector3(
            Mathf.Cos(yRadian) * Mathf.Sin(xRadian),
            Mathf.Sin(yRadian),
            Mathf.Cos(yRadian) * Mathf.Cos(xRadian)
        );
    }
```

## 障害物チェック
### なぜ作るか
- 被写体とカメラの間に障害物がある場合に被写体が映るように近づくのがよい

![ブログ用_障害物.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/207b8476-945d-a1da-c27b-0d8b6339ea9f.gif)

### 実装
- raycastしてカメラ位置を調整

```C#
    Vector3 CheckRaycast(Vector3 basePos, Vector3 cameraPosition, float rayDistance)
    {
        // 被写体からRayを飛ばして障害物チェック
        var rayDirection = cameraPosition - basePos;
        if (Physics.SphereCast(basePos, CameraCollisionRadius, rayDirection,
            out hitInfo, rayDistance))
        {
            // 障害物にぶつかった場所をカメラの位置にする
            return hitInfo.point + hitInfo.normal * CameraCollisionRadius;
        }
        return cameraPosition;
    }
```


## 自動で後ろに回り込むカメラ
### なぜ作るか
- スマホでカメラ操作をするのはかなり手間
  - 移動方向が見えるようにカメラを後ろに回り込ませる
  - これがあると通常移動ではカメラ操作がいらなくなる

![ブログ用_回り込み.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/26964e26-6110-8750-4682-2383374b9ec7.gif)

### 実装
- 移動方向の内積チェックで逆方向にカメラ水平角を変える
  - 移動方向がカメラから見て右か左かは内積で確認
- カメラ横軸に移動方向を投影して回り込み角度に変換
  - カメラの手前と奥側に行く場合には回り込みを遅くしたい
  - 等速だとキーボード入力で前方移動時にカメラがガクガク揺れてしまう

```C#
    void UpdateAngle(Vector3 playerPos)
    {
        var moveDir = playerPos - prevPlayerPos;
        if (prevPlayerPos != Vector3.zero && moveDir != Vector3.zero)
        {
            // Playerの移動値をカメラ横軸に投影してxAngleに代入
            var project = Vector3.Project(moveDir, transform.right);
            var diff = project.sqrMagnitude;
            var dot = Vector3.Dot(moveDir, transform.right);
            horizontalAngle += diff * autoRotateSpeed * (dot > 0 ? 1 : -1);
        }
    }
```

## ロックオンカメラとの切り替え
### なぜ作るか
- 戦闘で敵をカメラにいれるのが面倒
- 雑魚ならいいけどボス戦はかなりしんどい

![ロックオン.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/27b8f63b-cdcf-bbc0-5e44-9aa9ce573f01.gif)

![ブログ用_ロックオン.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7680/bcb00a24-e7ef-6cfe-1aa5-bbccee97d7b2.gif)

### 実装
- 別のVCamを設定。優先度設定
    - カメラ切替処理はCinemachineBrainのDefaultBlendsを使うと良いです
- TargetGroupを自前実装
  - playerのSerialzieFieldとenemyのSerializeFieldを用意
  - CinemachineComponsentBase側からAbstractLookAtTargetGroupで呼び出せる

```c#
public class LockOnTargetGroup : MonoBehaviour, ICinemachineTargetGroup
{
    [NoSaveDuringPlay]
    [SerializeField] public Transform playerTarget;

    [NoSaveDuringPlay]
    [SerializeField] public Transform enemyTarget;
}

public class CinemachineLockOnBody : CinemachineComponentBase
{
    [SerializeField, Tooltip("中心位置のオフセット")] 
    private Vector3 centerOffset;

    [SerializeField, Tooltip("タゲからみて自機のどこにカメラを置くか")]
    private Vector3 offset;

    public override void MutateCameraState(ref CameraState curState, float deltaTime)
    {
        targetGroup = AbstractLookAtTargetGroup as LockOnTargetGroup;

        var basePos = FollowTargetPosition + centerOffset;

        // タゲから自機へのベクトル
        var dir = targetGroup.playerTarget.position - targetGroup.enemyTarget.position;

        // Offsetを加味してポジション計算
        var newPos = basePos + targetGroup.playerTarget.position + Quaternion.LookRotation(-dir) * offset;
        
        curState.RawPosition = newPos;
    }
}
```

## まとめ
時間とアセットの問題で攻撃周りは作れませんでしたが
バトルだけでなくマップ探索にも使えるカメラワークができました

カメラ位置を上下角と水平角で管理すると
いろいろと応用が効くのでオススメです

ご視聴ありがとうございました

## バージョン情報
Unity: 2021.3.23.f1
Cinemachine: 2.8.9

## コード
サンプルプロジェクトは下記にコミットしてあります
気になる方はどうぞ

https://github.com/tsune2ne/ActionGameCameraSample