Skip to content

第02回 カメラ操作とコンポーネント分割

前回: 第01回 Input Systemとキャラクター操作 / 次回: 第03回 衝突判定とダメージ

今回の授業内容

Playerを注視しながら回転するカメラの配置例

  • 操作対象に「カメラ」を追加し、カメラの向きに合わせて移動方向を加工する
  • 注視対象・オフセット・左右と上下の回転を扱うTPS型のカメラを作る
  • 入力・移動・カメラの機能を別々の コンポーネントに分割し、拡張可能な設計手法を理解する
  • GetComponentSerializeFieldを使い分け、コンポーネント参照を接続する

今回の配布コード

第01回のPlayerControllerから移動処理を分離し、カメラの追従と回転を追加したコードです。3ファイルを同じAssets/Scriptsへ配置してください。

ファイル内容
PlayerController.cs入力を読み取り、移動とカメラへ処理を渡す
PlayerMove.csプレイヤーの移動と振り向き
CameraMove.csカメラの追従、回転、移動基準ベクトルの提供

プレイヤーの操作とカメラの操作の共存

前回はプレイヤーを動かすことだけを考えて PlayerController に入力とプレイヤー移動の処理をまとめて書きました。今回はそこに「カメラの操作」が加わります。

プレイヤーはキーボードや左スティックで、カメラは右スティックやマウスで操作できるものとします。つまりプレイヤーとカメラ、同時に2つの対象を操作することになります。この場合、入力の受付はどこで行うべきでしょうか?

TPS型カメラの仕様を決める

カメラは 注視ターゲット を中心に動きます。ターゲットの周りをぐるりと回る、肩越し視点のカメラです。

カメラ位置は、ターゲットからのオフセット(XYZの相対的な距離)で決定します。回転は Y軸周りの左右回転量 _yaw と X軸周りの上下回転量 _pitch で決定します。

注視ターゲットを中心にカメラを回す

上下左右の回転軸での回転は Quaternion.Euler() で求めます。

上下方向の回転が無制限だと、床にめり込んだり天井を通り越して上下が逆になったりしてしまいます。ここでは _pitch を上下に動きすぎないよう範囲制限しています。

cs
    [SerializeField] private float lookSpeed = 120f;
    [SerializeField] private float minPitch = 10f;
    [SerializeField] private float maxPitch = 75f;

    private float _yaw;
    private float _pitch = 60f;

    private Quaternion CameraRotation =>
        Quaternion.Euler(_pitch, _yaw, 0f);

    // PlayerControllerから回転入力を受け取る
    public void Look(Vector2 lookInput)
    {
        _yaw += lookInput.x * lookSpeed * Time.deltaTime;
        _pitch -= lookInput.y * lookSpeed * Time.deltaTime;
        _pitch = Mathf.Clamp(_pitch, minPitch, maxPitch);
    }

カメラ位置は target.position + 回転 * offset で求めます。回転をオフセットに掛けることで、ターゲットの周りを公転するカメラを作ることができます。

cs
    [SerializeField] private Vector3 offset = new Vector3(0f, 1.5f, -6f);

    private void LateUpdate()
    {
        transform.rotation = CameraRotation;
        transform.position =
            target.position + transform.rotation * offset;
    }

プレイヤーの移動結果に追従するため LateUpdate で処理します。Update で処理すると、プレイヤーの移動前の位置を基準にカメラが動いてしまう場合があります。

密結合が変更を難しくする

多くの場合、ゲーム内でのカメラはプレイヤーを追いかけるように動きます。プレイヤーも、カメラの向きを基準に移動方向を決定します。

こうして思えば、プレイヤーがカメラを操作しても問題はなさそうです。ですが、カメラにはムービーシーン中の演出を扱う、レーダー視点を扱うなど、プレイヤーの操作とは別の役割もあるかもしれません。

プレイヤーのコードにカメラの操作を直接書いてしまうと、プレイヤーとカメラが密に結合してしまいます。プレイヤーに直接紐づけてしまうと、プレイヤーがカメラ操作に関わる操作のすべてを担うことになります。

1つのクラスへまとめた場合

入力・プレイヤー移動・カメラの追従と回転を1つのクラスに書いても、見通しよく書きあげることは十分可能です。ですが、このために用意されたクラスは、入力・プレイヤー移動・カメラ追従と回転のすべてを同時に扱うことになります。これらは本来、別々の役割を持つ機能です。

カメラとプレイヤーの関係が密になりすぎると、修正や調整の際に変更しなければならないコードの境界が曖昧になり、カメラだけやプレイヤーだけの再利用やテストも難しくなります。

カメラに関する変更が行われる度にプレイヤーのコードも変更しなければならない構造は、あまり望ましくありません。変更の量が少ない間は問題ないかもしれませんが、カメラの変更の都度プレイヤーのコードも変更しなければならない展開が続くと作業効率は悪化しますし、予期しない不具合の原因にもなります。

ゲームを効率よく開発するためには、プレイヤーやカメラといった機能をそれぞれの役割ごとに分割し、必要な情報だけをやり取りするように設計することが望ましいです。

役割ごとにコンポーネントを分割した場合

今回は、入力、プレイヤー移動、カメラ操作を次の3つのクラスへ分けます。

クラス担当する役割
PlayerController入力の受け付けと解釈
PlayerMove指定された方向への移動と振り向き
CameraMove注視対象への追従とカメラの向きの操作

役割ごとに分けることで各クラスのコードが単純になり、機能単位のテストや調整もしやすくなります。

コンポーネント志向

機能ごとに小さな部品(コンポーネント)に分けることで各部品を単独で動かすことができれば、テストや機能検証は部品単位で行うことができ、実装もシンプルになります。信頼できる機能部品同士を接続することで、より複雑なソフトウェアを構築する志向を コンポーネント志向 と呼びます。独立性の高いコンポーネントは再利用性も高くなります。Unity の MonoBehaviour は、まさにこの考え方で設計されています。

コンポーネントの接続を整理する

PlayerControllerとPlayerMoveとCameraMoveの接続関係 コンポーネントの接続の様子。

PlayerControllerMoveLook の入力をまとめて受け取ります。解釈した結果を、それぞれの担当に渡します。

Playerコンポーネントの接続
Move / Look入力
入力を受け付けて解釈する
PlayerController
PlayerController
回転入力を渡す
CameraMove.Look
CameraMove
水平なForwardとRightを返す
PlayerController
PlayerController
移動方向を渡す
PlayerMove.Move

PlayerMove でプレイヤーの移動と回転を扱う

PlayerMove はプレイヤーが 「どの方向に向かって動くか」だけを扱うクラスとします。入力やカメラのことは一切知りません。このため移動のロジックを単独でテストしたり、演出でのプレイヤーの自動操縦などにも利用できるようになります。

cs
using UnityEngine;

public class PlayerMove : MonoBehaviour
{
    [SerializeField] private float moveSpeed = 5f;
    [SerializeField] private float rotationSpeed = 720f;

    public void Move(Vector3 direction)
    {
        transform.position +=
            direction * moveSpeed * Time.deltaTime;

        if (direction == Vector3.zero)
            return;

        var targetRotation =
            Quaternion.LookRotation(direction);

        transform.rotation = Quaternion.RotateTowards(
            transform.rotation,
            targetRotation,
            rotationSpeed * Time.deltaTime
        );
    }
}

CameraMove でカメラの注視と回転を扱う

PlayerMove 同様に、カメラを動かす CameraMove が必要です。CameraMove は 「カメラが何を追い、どこを向くか」だけを扱うクラスとします。入力の読み取りやプレイヤー移動は行いません。

cs
using UnityEngine;

public class CameraMove : MonoBehaviour
{
    [SerializeField] private Transform target;
    [SerializeField] private Vector3 offset = new Vector3(0f, 1.5f, -6f);
    [SerializeField] private float lookSpeed = 120f;
    [SerializeField] private float minPitch = 10f;
    [SerializeField] private float maxPitch = 75f;

    private float _yaw;
    private float _pitch = 60f;

    private Quaternion CameraRotation =>
        Quaternion.Euler(_pitch, _yaw, 0f);

    // PlayerControllerから回転入力を受け取る
    public void Look(Vector2 lookInput)
    {
        _yaw += lookInput.x * lookSpeed * Time.deltaTime;
        _pitch -= lookInput.y * lookSpeed * Time.deltaTime;
        _pitch = Mathf.Clamp(_pitch, minPitch, maxPitch);
    }

    private void LateUpdate()
    {
        transform.rotation = CameraRotation;
        transform.position =
            target.position + transform.rotation * offset;
    }
}

CameraMoveをMain Cameraに設定

Main CameraへCameraMoveを設定したInspector メインカメラに CameraMove を追加した様子。

CameraMove が書きあがったら Main Camera に追加します。CameraMove の target に Player の Transform を設定します。以降、カメラはプレイヤーに追従するようになります。

入力をカメラ基準の移動に変換

これまでのプレイヤーの移動は、入力値をそのままワールド座標の移動方向として使っていました。ですが通常、プレイヤーは画面上の前後左右を基準に操作方向を入力します。入力値をそのままワールド座標の移動方向として使うと画面の向きと移動方向が一致しないことになってしまいます。

そのため、入力方向とカメラの向きを組み合わせて、プレイヤーの移動方向を決定する必要があります。この変換のために必要な情報はカメラが持っていることになります。ここではカメラに「前方向」と「右方向」を提供してもらうことにします。

カメラの姿勢から水平軸のベクトルを作る

CameraMoveは _yaw_pitch から、現在の回転を作ります。その回転を Vector3.forwardVector3.right に掛けることで、カメラ基準の前方向と右方向のベクトルを作成できます。

cs
    // カメラの向きを基準にした前方向
    var forward = CameraRotation * Vector3.forward;

    // カメラの向きを基準にした右方向
    var right = CameraRotation * Vector3.right;

ですが、カメラが上下方向に傾いている場合、得られたベクトルは地面に対して斜めになってしまいます。

Y成分を除いて正規化する

今回プレイヤーの移動はXZ平面(水平面)に限定したいので、ベクトルから上下成分を取り除かなければなりません。取り除いた後は長さが変わるため、長さを1に戻して正規化します。これでカメラの向きを基準にした水平な前方向と右方向が得られます。

cs
        var forward = CameraRotation * Vector3.forward;
        forward.y = 0f;
        return forward.normalized;

プレイヤーの移動方向を得るためには、カメラの現在の回転情報が必要です。ここではカメラが「移動の基準になる水平な向き」を外へ提供することにします。

cs
// CameraMove に追加する

public Vector3 PlanarForward
{
    get
    {
        var forward = CameraRotation * Vector3.forward;
        forward.y = 0f;
        return forward.normalized;
    }
}

public Vector3 PlanarRight
{
    get
    {
        var right = CameraRotation * Vector3.right;
        right.y = 0f;
        return right.normalized;
    }
}

設計の選択肢

今回は CameraMove が地面に対して水平な前方向・右方向を提供していますが、計算処理を別の場所に移してもよいでしょう。CameraMove は「カメラの回転」を提供するだけにして、水平な前方向・右方向の計算は PlayerController で行っても構いません。どちらが適切か、考えてみてください。

PlayerControllerから各機能を呼び出す

cs
using UnityEngine;

public class PlayerController : MonoBehaviour
{
    [SerializeField] private PlayerMove playerMove;
    [SerializeField] private CameraMove cameraMove;

    private InputSystem_Actions _inputActions;

    private void Awake()
    {
        _inputActions = new InputSystem_Actions();
    }

    private void OnEnable()
    {
        _inputActions.Player.Enable();
    }

    private void OnDisable()
    {
        _inputActions.Player.Disable();
    }

    private void Update()
    {
        var moveInput =
            _inputActions.Player.Move.ReadValue<Vector2>();
        var lookInput =
            _inputActions.Player.Look.ReadValue<Vector2>();

        // カメラを回す
        cameraMove.Look(lookInput);

        // カメラの向きを基準に移動方向を作る
        var forward = cameraMove.PlanarForward;
        var right = cameraMove.PlanarRight;
        var direction =
            forward * moveInput.y + right * moveInput.x;
        direction =
            Vector3.ClampMagnitude(direction, 1f);

        // 実際の移動はPlayerMoveに任せる
        playerMove.Move(direction);
    }
}

コンポーネントの疎結合

PlayerMove は入力やカメラの処理から切り離されています。単独で動作させられますので、イベントシーンでの自動操縦などにも利用できます。

また、CameraMove も、カメラの回転や移動の操作を提供しているだけです。入力の対応は PlayerController の仕事なので、操作方法が変更されてもカメラのコードは影響を受けずに済みます。

カメラの実装を差し替えてもプレイヤーの移動側のコードは変えずに済みますし、プレイヤーの操作方法を変えてもカメラのコードは影響を受けずに済みます。このように互いの部品のつながりが疎であれば、各部品を単独で運用することができ、テストも実装も再利用もしやすくなります。

コンポーネント参照の解決方法

参照の解決方法には、コード上で動的に解決する方法と、インスペクターで静的に割り当てる方法があります。

動的に解決する方法

動的な解決の例としては、GetComponent() などでコード上で参照を解決する方法があります。コードを見ただけで必要なコンポーネントがわかりますが、オブジェクトの階層構造の変更に弱く、目的となるコンポーネントを確実に取得できない場合があります。Unity のプロジェクトはソースコード以外にもシーンやプレハブも更新され続けるため、意図せず必要な参照が得られなくなる可能性があり、利用には注意が必要です。

静的に解決する方法

静的な解決には、SerializeField を用いて Inspector から指定する方法があります。確実に参照を指定できますが、なんでも割り当てられる分どこに依存しているかコード上からはわからないため、無計画に使うと参照関係が複雑になり、正常な動作を得るのが難しくなるかもしれません。

動的解決のベストプラクティス

GetComponent と RequireComponent を併用する

GetComponent() は、同じ GameObject のコンポーネントをコードから取得する関数です。取得するコンポーネントは同じ GameObject に存在する必要があります。

GetComponent() 使用の際は RequireComponent 属性の併用を強くおすすめします。対象のコンポーネントを Unity が自動で追加してくれるようになり、取得の失敗を考慮せずに済みますし、コード上も「このコンポーネントが必要」と明示できるため、可読性も向上します。

cs
// 必要なコンポーネントをコード上で明示する
[RequireComponent(typeof(PlayerMove))]
public class PlayerController : MonoBehaviour
{
    private PlayerMove _playerMove;

    private void Awake()
    {
        // PlayerMove は RequireComponent で追加されているので、必ず取得できる
        _playerMove = GetComponent<PlayerMove>();
    }

    private void Update()
    {
        // 中略

        // 取得したコンポーネントの機能を利用する
        _playerMove.Move(direction);
    }
}

親子関係から取得する

親子関係を辿ってコンポーネントを取得する関数も存在します。GetComponentInParent() は親子関係を上に辿り、GetComponentInChildren() は親子関係を下に辿ってコンポーネントを取得します。これらの関数は階層構造を利用して参照を解決することができますが、最初に見つけたコンポーネントを返すため、階層構造の変更によって結果が変わってしまう可能性があります。特定のオブジェクトのコンポーネントを取得したい場合は SerializeField でインスペクターから割り当てる方が安全です。

複数のコンポーネントを同時に取得する

GetComponents()GetComponentsInParent()GetComponentsInChildren() は複数のコンポーネントを同時に取得することができる関数です。対象となる型のコンポーネントをすべて取得できるため、例えばUI部品のすべてのテキストを取得して一括で変更するなどの用途に利用できます。特に GetComponentsInChildren() は配下のコンポーネントをまとめて制御したい場合に便利です。

参照を取得できない場合を考慮する

参照を用いるコードは、参照先が存在しない場合の挙動を考慮する必要があります。GetComponent() で動的に解決する場合は、目的のコンポーネントが存在しない可能性を考慮しなければなりません。

また、目的のコンポーネントがまだ初期化されていない場合も考えなければなりません。Awake() より前に呼び出されるコードで GetComponent() を利用する場合は、特に注意が必要です。

加えて、GetComponentInParent()GetComponentInChildren() を利用して参照を解決する場合は、階層の変更によって目的のコンポーネントが取得できなくなったり、意図しないコンポーネントが取得される可能性にも配慮が必要となります。

動的な参照は便利ですが、例外的な条件が混入して失敗したり、意図しないコンポーネントを取得してしまう危険が「無い」場合に限って利用することをおすすめします。

静的解決のベストプラクティス

SerializeFieldでの割り当てはPrefab内で完結させる

Unity では SerializeField を使うことで、コードの変更なしで Inspector からパラメーターを調整することができます。パラメーターに限らず、参照するコンポーネントも SerializeField を使って Inspector から割り当てることができます。

ですが自由に割り当てられる分、コードからはコンポーネントの動作に必要な参照が何かを把握できなくなってしまうことがあります。例えば PlayerControllerPlayerMoveCameraMove を参照していますが、CameraMove は暗黙のうちに登場しており、コードだけでは所在がわかりません。暗黙の参照は、シーンやPrefabの階層構造の変更によって失われる可能性がありますが、避けることはできません。

こうした場合、SerializeField でのオブジェクト参照は Prefab 内で完結させる のがひとつの目安になります。GetComponent() 系の関数で動的に解決することもできますが、成功を保証できず、階層変更の影響を考慮する必要があります。

Prefab 内で参照が完結していれば参照先をセットで管理できますし、階層構造の変更などで参照が失われることもありません。さらに、参照が Prefab 内で完結していれば、Prefab を複製しても参照関係は維持されます。SerializeField を用いる際は、参照はなるべく Prefab 内で完結させるのが安全です

参照方法の選択基準

  1. 同じオブジェクトに存在するコンポーネントを参照する場合は GetComponent()RequireComponent の併用で解決する
  2. 特定の参照が必要な際は SerializeField で静的に解決するが、参照先は可能な限り Prefab 内で完結させる
  3. 参照の解決に階層構造を利用する場合は GetComponentInParent()GetComponentInChildren() を使用するが、失敗や誤取得の可能性を考慮する

どの方法が適切かは状況によりますが、上のリストの順に選択するのがおすすめです。

実習:カメラ操作を分割して実装する

  1. Look Actionを追加する(型は Vector2
  2. PlayerMove を作り、Playerに追加する
  3. CameraMove を作り、Main Cameraに追加する
  4. CameraMovetarget にPlayerのTransformを設定する
  5. PlayerController を更新し、playerMovecameraMove を参照する
  6. Inspectorで playerMovecameraMove を割り当てる
  7. カメラ回転後も入力方向が自然か確認する

動作しない場合のチェックリスト

  • Look Actionの型が Vector2 になっているか
  • playerMovecameraMove がInspectorで割り当て済みか
  • CameraMovetarget がPlayerになっているか
  • offset が近すぎたり、pitch の範囲が狭すぎたりしないか
  • カメラがプレイヤーの移動後に追従しているか(LateUpdate
  • 移動方向が斜め下を向いていないか(y を0にしたか)

完成条件

  • WASDまたは左スティックで移動できる
  • マウスまたは右スティックでカメラを操作できる
  • カメラの向きを基準に前後左右が決まる
  • PlayerController / PlayerMove / CameraMove に分割している
  • playerMovecameraMove をInspectorから参照している
  • CameraMove がプレイヤーを追従している

まとめ

  • 入力の受付と解釈は PlayerController に集約する
  • プレイヤーの移動と振り向きは PlayerMove が担当する
  • カメラの追従と回転は CameraMove が担当する
  • カメラの向きから地面に水平な移動基準ベクトルを作る
  • コンポーネント同士は必要な情報と参照だけで接続する