Unityで、よく見る3人称カメラ周りの機能を作る



IPFactory Advent Calender 2023 20日目の記事です。
全ての記事はこちら▼

https://qiita.com/advent-calendar/2023/ipfactory

# はじめに #############################################################

この記事に来ていただき、ありがとうございます。

Unityで、よくある挙動の3人称カメラを作りました。
簡単な技の組み合わせですが、必要な時パッと作れるような備忘録みたいな感じで使えればと思います。

..スクリプトが散乱していますが、最終的にまとめたものも載せております。


# 完成品・目標 #########################################################

動いてる動画リンク貼らなきゃ

- カメラが回転・ズームする
- プレイヤーに追従
- 床や障害物へのめり込みを回避
- プレイヤーがカメラ方向を基準に操作

# 準備 #################################################################

### オブジェクトの配置

プレイヤーは`Rigidbody`,`CapsuleCollider`を適用しています。

プレイヤーとカメラは親子関係にせず、独立で動かします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625507/97244487-6f99-0144-0c1d-c1bac5343751.png)



### 入力

入力周りの準備をします

```java:カメラ入力()
//outside
private float rotation_hor;
private float rotation_ver;
private float distance_base;

//void Start()
    rotation_hor = 0f;
    rotation_ver = 0f;
    distance_base = 5.0f;

//void Update()
    //zoom-scrolling  FixedUpdateだめかも
    distance_base -= Input.mouseScrollDelta.y * 0.5f;
    if (distance_base < 1.0f) distance_base = 1.0f;

//void FixedUpdate()
    //camera rotation
    rotation_hor += Input.GetAxis("Mouse X") * 3;
    rotation_ver -= Input.GetAxis("Mouse Y") * 1.5f;
    
    //restrict vertical angle to -90 ~ +90
    if ( Mathf.Abs(rotation_ver) > 90 ) rotation_ver = Mathf.Sign(rotation_ver) * 90;
```

# カメラ回転 ############################################################

### Quaternion.Euler
`Quaternion.Euler`の便利な特徴として、軸を回転する順番があります。
y→x→z （ yaw→pitch→roll ）
これをそのままマウス入力と合わせ、カメラの回転に転用します。

```diff_java:カメラ回転
//void FixedUpdate() 
    //base vector to rotate
    var rotation = Vector3.Normalize( new Vector3(0, 0.2f, -5) ); //base(normalized)
    rotation = rotation * Quaternion.Euler (pitch, yaw, 0) //rotate vector
    
    //turn self
    transform.rotation= Quaternion.Euler (pitch, yaw, 0); //Quaternion IN!!
    
    //turn around + zoom
    transform.position= rotation * distance;
    
    //rotation center to neck-level
    var necklevel = Vector3.up*1.7f;
    transform.positon += necklevel;
```
カメラ自身の回転は、Quaternionを直接代入しました。

プレイヤの周回では、ベクトルの回転を利用します。
回転用のベースベクトルにQuaternionを掛け、回転させます。

### カーソルロック
マウスカーソルの固定も追加しておきます。
Escキーで固定解除可能
```java
//void Start()
    //cursor lock : Esc to exit
    Cursor.visible = false;
    Cursor.lockState = CursorLockMode.Locked;
```

# プレイヤー追従 ##########################################################

今回は滑らかな追従にしてみます。

追従させる前に、まずはplayerを設定しましょう。
### Playerゲームオブジェクト取得
```java
//outside 
public GameObject player;

//void Start()
    player = GameObject.FindWithTag("Player");
```

### Vector3.Lerp

カメラ回転の中心のみ、多少のスムーズさを加えます。
（カメラ自体はこの中心点のみを向くため、プレイヤーがあまり早く動きすぎると追いつかず画面外に飛んでいきます。場合によって変えた方がよさげ）

```java
//void FixedUpdate()
    playertrack = Vector3.Lerp(
        playertrack, player.transform.position, Time.deltaTime * 10);
    transform.position += playertrack;
```


# めり込み回避 ############################################################


### Physics.SphereCast

RayCastの球体版。半径を持った太い光線を放つようです。
今回は着地点が壁から安全に離すため、こちらを使用します。
（壁ギリギリでカメラを向けると貫通するため）

#### layermask
そのために、まずはlayermaskの準備をします。
障害物レイヤーを追加し、SphereCastを障害物だけに反応させる設定です。

今回は**6層目**に`Floor_obstacle`という名前で設定しました。
床オブジェクトにこのレイヤーを適用します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625507/00830fe9-dde1-6076-c128-cf96ef9e9f79.png)

レイヤーマスクの設定は[こちらのサイト](https://kan-kikuchi.hatenablog.com/entry/RayCast2)を参考にしました。

#### スクリプト
layermaskを準備したところで、スクリプトを書いていきます。

```diff_java
//void FixedUpdate()
    RaycastHit hit;
    int layermask = 1 << 6; //1のビットを6レイヤー分(Floor_obstacleがある場所)だけ左シフト
    float distance = distance_base; //copy default(mouseScroll zoom)
    
    if (Physics.SphereCast(playertrack + Vector3.up * 1.7f, 0.5f,
        rotation, out hit,  distance, layermask))
    {
        distance = hit.distance; //overwrite copy
    }
```
発射地点はカメラ回転の中心と同じ位置、半径は0.5...と設定していきます。
このコードを、rotationベクトルを定義したところに挿入しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3625507/f7974772-c759-b2df-8cf4-48bafaae19fb.png)
▲イメージ図

カメラの動きを客観的にみると、こんな感じになっているかと思います。

# カメラ優先プレイヤー操作 ################################################

次はプレイヤー側の作成です。

### mainCameraゲームオブジェクト
カメラのy軸回転を使うため、GameObjectごと持ってきます。
ついでに都合上Rigidbodyも持ってきています
```java
//outside
public GameObject mainCamera;
private Rigidbody rb;

//void Start()
    mainCamera = GameObject.FindWithTag("MainCamera");
    rb = GetComponent<Rigidbody>();
```

### 入力方向を回転させる
```diff_java
//void FixedUpdate()
    //input
    float in_x = Input.GetAxis("Horizontal");
    float in_z = Input.GetAxis("Vertical");
    Vector3 in_dir = new Vector3(in_x,0,in_z); //input_direction

    //斜め入力をしたとき、プレイヤー速度が√2になるのを防ぐ
    if (in_dir.magnitude > 1)in_dir.Normalize();
        
    //rotate input to camera direction
    in_dir = Quaternion.Euler(0, mainCamera.transform.rotation.eulerAngles.y, 0) * in_dir;

    //移動
    rb.AddForce(in_dir * 50, ForceMode.Force);
```

移動はRigidbodyに力を加える感じです。

√2の件は、[イメージ](https://scratch.mit.edu/projects/927887572/)をScratchで作ってみました

### プレイヤーの向きを合わせる

プレイヤの向きは私の場合、入力があったときのみ更新するようにしました。
常にカメラと同じ方向を向かれたら、後ろ姿しか見えませんから。

`Quaternion.LookRotation`を使用しています。

```diff_java
//void FixedUpdate()
        if ( in_dir.magnitude > 0 ) {
            transform.rotation = Quaternion.LookRotation( in_dir );
        }
```

# 完成品 ################################################################

最後にこれまでのを組み合わせて...
出来上がったものがこちらになります。

```java:TPSCamera.cs
using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;

public class TPSCamera : MonoBehaviour
{

    public GameObject player;

    private float rotation_hor;
    private float rotation_ver;
    private float distance_base;
    private Vector3 playertrack;

    // Start is called before the first frame update
    void Start()
    {
        player = GameObject.FindWithTag("Player");

        rotation_hor = 0f;
        rotation_ver = 0f;
        distance_base = 5.0f;
        playertrack = Vector3.zero;

        //cursor lock : Esc to exit
        Cursor.visible = false;
        Cursor.lockState = CursorLockMode.Locked;

    }

    // Update is called once per frame
    void Update()
    {
        //zoom-scrolling  FixedUpdateだめかも
        distance_base -= Input.mouseScrollDelta.y * 0.5f;
        if (distance_base < 1.0f) distance_base = 1.0f;

    }

    void FixedUpdate()
    {
        //camera rotation
        rotation_hor += Input.GetAxis("Mouse X") * 3;
        rotation_ver -= Input.GetAxis("Mouse Y") * 1.5f;

        //restrict vertical angle to -90 ~ +90
        if (Mathf.Abs(rotation_ver) > 90) rotation_ver = Mathf.Sign(rotation_ver) * 90;

        //base vector to rotate
        var rotation = Vector3.Normalize(new Vector3(0, 0.2f, -5)); //base(normalized)
        rotation = Quaternion.Euler(rotation_ver, rotation_hor, 0) * rotation; //rotate vector

        //stop at floor-obstacle layer
        RaycastHit hit;
        int layermask = 1 << 6; //1のビットを6レイヤー分(Floor_obstacleがある場所)だけ左シフト
        float distance = distance_base; //copy default(mouseScroll zoom)

        if (Physics.SphereCast(playertrack + Vector3.up * 1.7f, 0.5f,
        rotation, out hit, distance, layermask))
        {
            distance = hit.distance; //overwrite copy
        }

        //turn self
        transform.rotation = Quaternion.Euler(rotation_ver, rotation_hor, 0); //Quaternion IN!!

        //turn around + zoom
        transform.position = rotation * distance;

        //rotation center to neck-level
        var necklevel = Vector3.up * 1.7f;
        transform.position += necklevel;

        //track
        playertrack = Vector3.Lerp(
            playertrack, player.transform.position, Time.deltaTime * 10);
        transform.position += playertrack;
    }
}

```


```java:PlayerCtrl.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PlayerCtrl : MonoBehaviour
{

    public GameObject mainCamera;
    private Rigidbody rb;

    // Start is called before the first frame update
    void Start()
    {
        rb = GetComponent<Rigidbody>();
        mainCamera = GameObject.FindWithTag("MainCamera");
    }

    // Update is called once per frame
    void FixedUpdate()
    {
        //input
        float in_x = Input.GetAxis("Horizontal");
        float in_z = Input.GetAxis("Vertical");
        Vector3 in_dir = new Vector3(in_x, 0, in_z); //input_direction

        //斜め入力をしたとき、プレイヤー速度が√2になるのを防ぐ
        if (in_dir.magnitude > 1) in_dir.Normalize();

        //カメラ方向に合わせる
        in_dir = Quaternion.Euler(0, mainCamera.transform.rotation.eulerAngles.y, 0) * in_dir;

        if (in_dir.magnitude > 0)
        {
            transform.rotation = Quaternion.LookRotation(in_dir);
        }

        //移動
        rb.AddForce(in_dir * 50, ForceMode.Force);
    }
}
```

# おわりに・余談 ###############################################

記録は以上になります。
いかがでしたでしょうか。

自分はこの手の記事を書くような経験が皆無だったため、読みづらかったかもしれません。精進します。

今回は比較的簡単な技の組み合わせで、標準的な？カメラを実装することができました。
昔はsinやcosの計算ずくで実装していたのですが、ベクトルを回転させるだけで解決するという発想が衝撃でした。

最後に

記事を読んでいただき、ありがとうございます。