Unityで三人称視点のキャラクターコントローラを作ってみよう(第4弾)

以前のチュートリアルはこちらです:

三人称視点のカメラ

一人称視点は、主人公目線でゲームの世界を見ています。1990年代は一人称視点のシューティングゲーム(FPS)の人気で、特に欧米では一時PCゲームの標準になったと言ってもいいです。今も一人称視点のゲームの他に、ゲームエンジンを利用した建築ビジュアライゼーションなどで採用されます。VRコンテンツもほとんどのコンテンツがほぼ必然的に一人称視点になっています。

二人称視点というのをあまり耳にする機会がありませんが、主人公を相手の視点で見ることです。デジタルゲームでは、数少ない実験的なタイトル以外にほとんど使われることがないようです。しかし、小説などで、「あなたは…」という口調で書かれている話は二人称視点になります。私が小学校のころ愛読していた「きみはどうする?」シリーズのゲームブックが典型的な二人称視点です。

このチュートリアルで作っている三人称視点のカメラは、中立的な位置から主人公やゲームの世界を写します。「私は」でも、「あなたは」でもなく、「彼女は、彼は」で始まるストーリーです。今は3Dゲームで最もよく使われるシステムです。因みに、現在の三人称視点ゲームの原型となっているのは任天堂の「スーパーマリオ64」ですが、ストーリーでは、ジュゲムブラザーズ目線でマリオを見ているので、厳密にいえば二人称視点のゲームになるかも知れません。

三人称視点カメラの種類

三人称視点と言っても、一人称視点と違って見せ方がいろいろあります。

完全固定カメラ

このカメラは位置も回転も固定で世界を写しています。もちろん、技術的に最も簡単で、今までのチュートリアルではカメラになんの工夫もしていないので、完全固定カメラになっています。完全固定カメラは「スペースインベーダー」や「パックマン」など、初期の2Dゲームで採用されていましたが、現在はカメラを全く動かさないゲームがほとんどありません。

固定角度カメラ

このカメラは、プレーヤーキャラクターなどを追跡しながら世界を常に同じ角度から移しています。

「あつまれ どうぶつの森」は、基本的に固定角度カメラを採用していますが、プレーヤーは3つの角度から視点を切り替えることができます。

自動操縦カメラ

このカメラはプレーヤーが直接操作するのではなく、ゲームが最適な角度を計算して自動的にカメラを動かして回転させます。この類のカメラはレーシングゲームでよく使われます。また、カットシーンもカメラの操作をプレーヤーにさせずに、予め決められた動きをします。

自由回転カメラ

カメラはプレーヤーキャラクターを追跡しながら、プレーヤーがキャラクターを中心にカメラを回転させます。地面に潜らないようにカメラの回転が制限されることが多いです。

半自由回転カメラ

基本的にプレーヤーはカメラを操作する事ができますが、キャラクターの後ろに戻ったり、敵に向かったりして同時に自動化された動きもあります。

Nier:Automata

「Nier:Automata」は2017年にリリースされたゲームです。基本的にオープンワールドのゲームで、典型的な三人称視点の自由回転カメラを採用していますが、最初のステージではカメラの制御方法が度々変わります。このゲームはある意味で「ゲームについてのゲーム」で、このイントロでカメラの操作を変えるだけで三人称視点の戦闘ゲームから、横スクロールのアスレチック系のゲームに、さらに弾幕ゲームに変わります。カメラ操作の可能性を最大限生かしたゲームとして非常に参考になります。

Cinemachine

三人称視点のカメラの実装がかなり複雑になります。幸いにUnityにCinemachineという協力な助っ人がいます。このパッケージを使用すれば、カメラの主な機能が用意されて、プログラムをほとんど書かずに済みます。

一人称視点のゲームはプレーヤーキャラクター目線で世界を見ているのでカメラの動きはプレーヤーの動きになるので比較的単純です。二人称視点とは、プレーヤーキャラクター以外のキャラクター目線で世界を見ることです。ゲームでは相当珍しいです。三人称視点のカメラはゲームのキャラクターから離れた位置から世界を見ています。

Cinemachineは基本的に通常のUnityのカメラの設定を記憶したバーチャルカメラを通して使います。異なる角度、位置だけでなく、異なる操作方法も別々のバーチャルカメラで設定します。一つのシーンに複数のバーチャルカメラが存在してもいいですが、画面を写すUnityのカメラは基本的に1つだけです。

Cinemachineの準備

パッケージマネージャからCinemachineをプロジェクトに追加します。

このチュートリアルはバーチャルカメラを一つだけ使います。Cinemachineをインストールして現れたメニューから「Create Virtual Camera」を選択します。

プレーヤーキャラクターの修正

今まで使っていたキャラクターの構造を少し変える必要があります。Cinemachineで自由回転のカメラを実装するために、カメラのターゲットとなるオブジェクトを追加します。このターゲットは単純にカメラが見る点です。通常キャラクターの肩あたりに付けますが、位置を自由に調整できます。大事なのはこのターゲットとメッシュを分離する事ですが、移動したときにターゲットがメッシュと一緒に回転してしまうとCinemachineの動作がおかしくなります。

プレーヤーの修正は最初のチュートリアルで作成した「Character」プレハブを編集して行います。

Characterの中に「AimTarget」という空のオブジェクトを追加します。カメラが狙う位置になるので、次は型の辺りに移動します。カメラが狙う位置を左右動かしたい場合、Cinemachineでは他の方法があるので、AimTargetは基本的にキャラクターの背骨に沿って上下に動かします。

次はこのAimTargetとメッシュの回転を切り離すために、AimTarget以外、Characterの中にあるすべてのゲームオブジェクトを「Avatar」という新しいゲームオブジェクトの中に入れます。

スクリプトの修正も少し必要です。Characterスクリプトを開いて、「avatar」という新しいパラメータを追加します。

public class Character : MonoBehaviour
{
    public Camera playerCamera = null; // プレーヤーのカメラ
    public GameObject avatar = null; // Avatarオブジェクトへの参照

プレハブを編集しているまま、インスペクターでavatarを指定します。

最後にキャラクターの向きを変えていたところはAvatarだけを回転させるように修正します。

この部分:

// プレーヤーの向きを変える
Vector3 rotateTarget = new Vector3(movement.x, 0, movement.z);
if (rotateTarget.magnitude > 0.1f)
{
    Quaternion lookRotation = Quaternion.LookRotation(rotateTarget);
    transform.rotation = Quaternion.Lerp(lookRotation, transform.rotation, turnSmoothing);
}

をこれに変えます。

// プレーヤーの向きを変える
if (avatar != null)
{
    Vector3 rotateTarget = new Vector3(movement.x, 0, movement.z);
    if (rotateTarget.magnitude > 0.1f)
    {
        Quaternion lookRotation = Quaternion.LookRotation(rotateTarget);
        avatar.transform.rotation = Quaternion.Lerp(lookRotation, avatar.transform.rotation, turnSmoothing);
    }
}

プレーヤーの追跡

プレハブの編集が終わったので、シーンに戻ります。Cinemachineのバーチャルカメラを作った時に「CM vcam 1」というオブジェクトがヒエラルキーに追加されました。これがバーチャルカメラです。

追加される「CM vcam1」というオブジェクトを選択して、インスペクターでプレーヤーキャラクターを追跡するように修正します。

「Follow」設定は追跡するオブジェクトで、Playerではなく、その中にある「AimTarget」を選びます。

バーチャルカメラの追跡方法(Body)を「Transposer」から「3rd Person Follow」に変えます。

Aim設定を「Composer」から「Do Nothing」に変えます。

この時点で動作を確認するとキャラクターがこういう風に正しく追跡されることが確認できます。

もし動かなければ、CharacterスクリプトのカメラとAvatarが正しく設定されていることを確認してください。

現状で固定角度のカメラになっています。これから自由回転のカメラに変えますが、その前にCinemachineが提供してくれるいくつかの重要な設定を試してみましょう。

まずeasing(減衰)です。

これは動きの滑らかさです。カメラが完璧にターゲットを追跡して、硬い棒で固定されているような動きは画面酔いの原因になります。通常、もっと柔らかく追跡したいので、減衰パラメータでどれほど柔らかく反応するかを調整します。

減衰のxを0にしてキャラクターの頭の上にマウスのカーソルを置くとカメラがぴったりと体と同期して左右動くことが確認できます。
減衰のxを1まで上げるとカメラがゆっくり加速、減速して追跡します。

次の「Shoulder Offset」パラメータは追跡のターゲットから右肩の位置までの距離を表します。Cinemachineの3rd Person Followは肩の上を覗いて撮影する様に作られています。

追跡で使われる肩の位置です。実際の肩の位置と合わせなくてもいいです。

プレーしてみると、キャラクターが画面中央ではなく、少し左の方に寄っています。Shoulder Offsetのx座標を大きくするとキャラクターがさらに画面の中央からずれます。

Shoulder Offset = (1, 0, 0)
Shoulder Offset = (0.2, 0, 0)

肩の位置よりもカメラが少し高くなっています。これが、「Vertical Arm Length」パラメータです。

Vertical Arm Length

「Camera Side」で右肩か左肩の方から撮影するかを指定します。1にすると右で、0は左です。キャラクターを画面の中央に置きたいなら、Camera Sideを0.5にします。

ここで設定できる最後のパラメータは「Camera Distance」です。カメラとターゲットの距離です。

Camera Distance = 1.5
Camera Distance = 10

通常のカメラと同様にレンズの視野角を変えることができます。

この下にある「ニアクリップ面」と「ファークリップ面」はUnityのカメラにもあるパラメータです。単位はメートルで、ニアクリップ面より近い面、ファークリップ面より遠い面が描画されません。

ニアクリップ面設定より近いジオメトリは透けてしまいます。

カメラの位置を細かく変えることができましたが、Cinemachineで肝心な角度を変える設定がどこにもありません。実は、3rd Person Followは追跡するオブジェクトに合わせてカメラの位置と角度を調整します。ということで、カメラの角度はAimTargetオブジェクトを回転させて調整します。

カメラがAimTargetと同じ方向に向いています。
AimTargetを回転させるとカメラが上の方に移動して高い位置から見下ろします。
カメラはAimTargetの青い矢印の方向に向きます。

自由回転のカメラ

プレーヤーが自由に操作してカメラが回転する様にしてみましょう。

最初にチュートリアルでプレーヤーの入力設定を作成した時にUnityがすでに視点変更の入力を登録してくれました。

移動と同じく、スクリプトで送信される入力データを取得します。この処理をCharacterスクリプトに追加するのが一つの方法ですが、Nier:Automataのように複雑にカメラの操作方法が変わるゲームを作るのが非常に難しくなります。こういうゲームを作るつもりがなくてもやはりカメラの操作とキャラクターの操作を分離が方がすっきりして分かりやすいです。

「CameraControl」という新しいスクリプトを作ります。

スクリプトの冒頭にInput SystemもCinemachineも使う宣言を行います。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
using Cinemachine;

public class CameraControl : MonoBehaviour
{

CharacterのOnMoveと同様にCameraControlクラスに「OnLook」というメソッドを追加します。

Vector2 cameraRotationInput = Vector2.zero; // カメラ操作入力を記憶するプロパティ

void Look(Vector2 input)
{
    cameraRotationInput = input;
}

void OnLook(InputValue inputValue)
{
    Look(inputValue.Get<Vector2>());
}

このスクリプトはキャラクターではなく、Cinemachineのバーチャルカメラに追加します。

プレーヤーの入力を受けるのにPlayer Inputコンポーネントを使いますが、マウスとキーボード、ゲームコントローラーなど各入力方法毎にPlayer Inputを一つだけ使うようになっています。この問題を解決するために、バーチャルカメラをPlayerの中に移動します。そもそもプレーヤーの属するカメラなのですっきりします。

Player InputコンポーネントのBehaviorを「Broadcast Messages」に変えます。これでバーチャルカメラも通知を受けます。

では、スクリプトの編集に戻ります。StartメソッドでCinemachineのバーチャルカメラとその「3rd Person Follow」拡張の参照を取得します。UpdateはFixedUpdatedに変えます。スクリプト全体がこうなります。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
using Cinemachine;

public class CameraControl : MonoBehaviour
{
    CinemachineVirtualCamera vCam = null;
    Cinemachine3rdPersonFollow follow = null;

    // Start is called before the first frame update
    void Start()
    {
        vCam = GetComponent<CinemachineVirtualCamera>();
        if (vCam != null)
        {
            follow = vCam.GetCinemachineComponent<Cinemachine3rdPersonFollow>();
        }
    }

    void FixedUpdate()
    {
        
    }

    Vector2 cameraRotationInput = Vector2.zero;

    void Look(Vector2 input)
    {
        cameraRotationInput = input;
    }

    void OnLook(InputValue inputValue)
    {
        Look(inputValue.Get<Vector2>());
    }
}

カメラの回転はFixedUpdateで行います。準備は:

void FixedUpdate()
{
    if (vCam != null)
    {
        Transform target = vCam.Follow; // バーチャルカメラの追跡ターゲットを取得
        if (target != null)
        {
            // ターゲットの回転をオイラー角度(x, y, z)で取得
            Vector3 targetEulerAngles = target.rotation.eulerAngles;
        }
    }
}

回転スピードをインスペクターで調整したいので、パブリックのパラメータをクラスに追加します。

public class CameraControl : MonoBehaviour
{
     public Vector2 rotationSpeed = new Vector2(180, 180); // 1秒間180度

FixedUpdateに戻って、とりあえずy軸の回転を実装します。回転軸はyですが、プレーヤーがスティックを横に倒した時に回転するので、読み込む入力値はxになります。

// ターゲットの回転をオイラー角度(x, y, z)で取得
Vector3 targetEulerAngles = target.rotation.eulerAngles;

// y軸の回転を変える
targetEulerAngles.y += cameraRotationInput.x * rotationSpeed.y * Time.fixedDeltaTime;

// オイラー角度をクオータニオンに変換して追跡ターゲットの回転を変える
target.transform.rotation = Quaternion.Euler(targetEulerAngles);

動作確認をしてみましょう。

これはまあまあいいですが、しばらく遊んで、いくつかのゲームと比較すると操作性が悪いです。なぜでしょう。

今はこの様にスティックの位置と回転速度が直線関係になっています。そのせいで、細かなずれに敏感になってしまいます。真横に倒したつもりでしたが、少し上にも倒しているので、カメラが縦に移動してしまいます。目指すのは、もっとこんな感じのカーブです。

スティックの位置は必ず-1と1の間に収まるので、べき乗を使えば上のような形に直せます。まず、調整用のパラメータをCameraControlに追加します。

public class CameraControl : MonoBehaviour
{
    public Vector2 rotationSpeed = new Vector2(-180, 180); // 1秒間90度
    public float inputMappingCurve = 5f;

FixedUpdateでは、回転させるコードを修正します。べき乗でもマイナス値が正しくマッピングされるために、計算が少しややこしくなります。

// ターゲットの回転をオイラー角度(x, y, z)で取得
Vector3 targetEulerAngles = target.rotation.eulerAngles;

float curve = Mathf.Max(1, inputMappingCurve);
Vector2 speed = new Vector2(
    Mathf.Pow(Mathf.Abs(cameraRotationInput.x), curve) * Mathf.Sign(cameraRotationInput.x),
    Mathf.Pow(Mathf.Abs(cameraRotationInput.y), curve) * Mathf.Sign(cameraRotationInput.y));

// y軸の回転を変える
targetEulerAngles.y += speed.x * rotationSpeed.x * Time.fixedDeltaTime;

// y軸と同様にx軸の回転を変える
targetEulerAngles.x += speed.y * rotationSpeed.y * Time.fixedDeltaTime;

x軸の回転は地面に潜らないように角度を制限しなければなりません。以下のパラメータを追加します。

public class CameraControl : MonoBehaviour
{
    public Vector2 rotationSpeed = new Vector2(180, 180); // 1秒間180度

    public float minCameraAngle = -45; // x軸回転の下限
    public float maxCameraAngle = 75; // x軸回転の上限

角度を制限する時にいつも注意しなければなりません。正しい方法は:

if (target != null)
{
    // ターゲットの回転をオイラー角度(x, y, z)で取得
    Vector3 targetEulerAngles = target.rotation.eulerAngles;

    // y軸の回転を変える
    targetEulerAngles.y += cameraRotationInput.x * rotationSpeed.y * Time.fixedDeltaTime;

    // y軸と同様にx軸の回転を変える
    targetEulerAngles.x += cameraRotationInput.y * rotationSpeed.x * Time.fixedDeltaTime;

    // target.rotation.eulerAnglesは0~360の角度を返す。これを-180~180に変える。
    if (targetEulerAngles.x > 180f)
    {
        targetEulerAngles.x -= 360f;
    }

    // この状態で値を制限する。
    // 「Clamp」は一つ目の引数を2つ目と3つ目の引数の間に制限するメソッド
    targetEulerAngles.x = Mathf.Clamp(targetEulerAngles.x, minCameraAngle, maxCameraAngle);

    // オイラー角度をクオータニオンに変換して追跡ターゲットの回転を変える
    target.transform.rotation = Quaternion.Euler(targetEulerAngles);
}

動作確認します。

これで完成に近いところまできましたが、もう一工夫が必要です。今はキャラクターをどの角度から見てもカメラとの距離が変わりません。しかし、ほとんどのゲームではカメラが低い位置に来た時にキャラクターに近づけて、上に上ると遠くなります。

「ゼルダの伝説 ブレス オブ ザ ワイルド」は特に極端な例ですが、ほとんどのゲームが同様の仕組みを採用しています。

この仕組みを追加したい場合、カメラの距離をバーチャルカメラ設定ではなく、新しいCameraControlスクリプトで行います。カメラの角度と関係なく調整したいので3つのパラメータに設定を分けます。

public class CameraControl : MonoBehaviour
{
    public Vector2 rotationSpeed = new Vector2(90, 90); // 1秒間90度

    public float cameraDistance = 2f; // 平均的なカメラの距離
    public float lowAngleDistanceRatio = 0.5f; // 低い時の距離の比率
    public float highAngleDistanceRatio = 3f; // 高い時の距離の比率

これを使って、FixedUpdateでカメラの距離を調整します。

// オイラー角度をクオータニオンに変換して追跡ターゲットの回転を変える
target.transform.rotation = Quaternion.Euler(targetEulerAngles);

// カメラの距離を調整する
if (follow)
{
    float anglePhase = (targetEulerAngles.x - minCameraAngle) / (maxCameraAngle - minCameraAngle);
    float lowCameraDistance = cameraDistance * lowAngleDistanceRatio;
    float highCameraDistance = cameraDistance * highAngleDistanceRatio;
    follow.CameraDistance = lowCameraDistance + (highCameraDistance - lowCameraDistance) * anglePhase;
}
正しく動作します。

障害物回避

三人称視点カメラの最も厄介な問題は障害物です。自由に動くカメラは壁を透き通ったり、柱や敵キャラクターの後ろになったりして、簡単に対策できない問題がたくさんあります。

まず、一番重要なのは三人称視点カメラにふさわしいレベルデザインです。

「アサシンクリードオデッセイ」で、狭いはずの墓の廊下が数メートルもの幅があります。これは壁が三人称視点カメラの回転を邪魔しない工夫です。

もう一つの方法は邪魔になっているオブジェクトを透明にする仕組みです。

「アサシンクリードオデッセイ」の一部のオブジェクトはカメラが近づくと半透明になります。柱が透けて見えるのにツタが不透明です。

このアプローチは描画の効率があまりよくないので、だいたい一部のオブジェクトのみが対象になります。

Cinemachineは障害物検出機能があるので、これを使いましょう。障害物でキャラクターが見えなくなったら、カメラが一時的にキャラクターに近づけます。これもよくある仕組みの一つです。

フィールドに障害物を立てます。

プレーヤー本人が障害物として認識されないように必ずタグを「Player」にしましょう。

そして、一部のオブジェクトを障害物ではないと指定できるようにしたいので、新しいレイヤーを追加します。

「透けて見える」レイヤを追加します。

カメラの障害物として認識されたくないオブジェクトのレイヤーをこの「See Through」にします。

例えば、フィールドにガラスの壁があるなら…

Cinemachineのバーチャルカメラのインスペクターで作業を続けます。「Camera Collision Filter」を「Nothing」から「Default」に変えます。

プレーヤーも無視したいので、「Ignore Tag」を「Player」にします。

カメラが障害物にどれほど近づけてもいいかを「Camera Radius」で調整します。この設定で動作確認しましょう。

壁の向こうに行かず、カメラがプレーヤーに近づけます。

これで基本的な三人称視点カメラの実装が位置完成します!しかし、これは「一応」の完成です。ゲームの仕様によってカメラに他の工夫が必要になるかも知れません。

Leave a Reply

Your email address will not be published. Required fields are marked *

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください