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

前回のチュートリアルでキャラクターのジャンプを実装しました。次に前後左右の移動を実装しましょう。

検証用にシーンを準備する

キャラクターの動きを検証する時に、坂と段に対する反応が大事になるので、検証用にシーンにいくつかのオブジェクトを配置したいですね。シーンにキューブを2個追加して、高さが異なる段を作るように配置します。一つの段は「登れる段」にして、もう一つは「登れない段」にします。高さはこれから調整できますが、登れる段は高さを地面から0.1ユニットにして、登れない段は地面から0.2ユニット高くなるようにします。

登れる段(青)と登れない段(赤)にそれぞれマテリアルを付けて分かりやすくしました。

同様に「登れる坂」と「登れない坂」を作ります。

登れる坂の傾斜はこれからスクリプトのパラメータになりますが、とりあえずの閾値として、45度にします。

まだカメラの追跡を実装していないので、ゲーム画面ですべてが見渡せるようにカメラを動かします。

移動スクリプトの基本

これからCharacterスクリプトを編集して、キャラクターが動くようにします。まず、以下のパラメータを追加します。

public class Character : MonoBehaviour
{
    public float runSpeed = 3f; // 走る速度
    public float acceleration = 10f; // 加速度
    public float maxGroundAngle = 45; // 登れる坂の傾斜の閾値

前のチュートリアルでプレーヤー入力を受け取るメソッドを作りました。

public void Move(Vector2 input)
{
    
}

void OnMove(InputValue inputValue)
{
    Move(inputValue.Get<Vector2>());
}

これを修正しますが、キャラクターを移動させるのはこのMoveメソッドの中ではありません。プレーヤーの入力が変わった時だけ呼び出されるからです。プレーヤーを動かすのはFixedUpdateの中で、毎フレーム動きを更新します。このため、Moveで行うのは入力をFixedUpdateで使えるようにプライベートプロパティで記憶するだけです。

Vector2 movementInput = Vector2.zero;
]
public void Move(Vector2 input)
{
    movementInput = input;
}

FixedUpdateの方でキャラクターを動かします。この処理は必ず、地面検出の判定をお行ってからでなければなりません。地面に立って、且つジャンプを開始していない時だけキャラクターを移動させます。(ゲームによって空中移動がありますが、とりあえず空中移動なしにします。)

void ApplyMotion()
{

}

void FixedUpdate()
{
    isOnGround = CheckForGround();
    if (rb.velocity.y < 0)
    {
        isJumping = false;
    }

    // ジャンプの高さ調節
    if (isJumping)
    {
        rb.AddForce(Physics.gravity * rb.mass * (gravityScale - 1f));
    }

    if (!isJumping && isOnGround) 
    {
        ApplyMotion();
    }
}

ApplyMotionというメソッドで動きに関する実装をまとめます。キャラクターが地面に立って、ジャンプを開始していなければこのメソッドを実行します。

void ApplyMotion()
{
    rb.AddForce(new Vector3(movementInput.x, 0, movementInput.y) * acceleration, ForceMode.Acceleration);
}

Rigidbodyに力を加えて入力の方向に加速します。加速度はインスペクターで調整できるようになっています。最後の「ForceMode.Acceleration」で最初の引数が絶対値の加速度を表していることを示します。加速度は入力のy値が3次元のz値になるので注意してください。

これで試してみるとキャラクターが動いているのが確認できます。しかしこの単純な実装は問題だらけです。まず、移動がカメラの視点を考慮していません。カメラの位置と角度が固定なら問題はないですが、多くの三人称視点ゲームではカメラが動いて、移動方向は世界の座標に対してではなく、カメラの視点に対して動く必要があります。

もう一つの問題は地面の傾斜を計算に入れていません。というか、地面の傾斜が現状のスクリプトでは分かりません。しかし、キャラクターが正しく移動するには、地面の傾斜に合わせて動く必要があります。

さらに現状では永遠に加速して速度が全く制限されていません。歩く速度、走る速度、二つの制限値を用意しましたが、これで加速を止める必要があります。これは一見簡単にできそうですが、意外と厄介ですよ。まず、この速度は絶対的な速度ではなく、地面に対する速度です。つまりここも地面の傾斜を計算にいれないといけません。これだけじゃないですよ!ゲームによってリフトなど地面となるオブジェクトが移動している場合もあります。そうすると、地面も動いていれば、速度制限は世界の座標に対してではなく、動くかも知れない地面に対して計算します

これらの計算を助けてくれるのはゲームプログラムの親友の一人:ベクトル演算です。皆さん、高校の数学Bをちゃんと受けていますよね?

地面の傾斜を取得する

地面の傾斜を取得するのは簡単です。地面検出メソッドを少し修正すればUnityがすでに計算してくれた値を取得するだけで済みます。

元々あったこのメソッドを修正します。

bool CheckForGround()
{
    float extent = Mathf.Max(0, capsuleCollider.height * 0.5f - capsuleCollider.radius);
    Vector3 origin = transform.TransformPoint(capsuleCollider.center + Vector3.down * extent) + Vector3.up * groundDistance;

    Ray sphereCastRay = new Ray(origin, Vector3.down);
    bool raycastHit = Physics.SphereCast(sphereCastRay, capsuleCollider.radius, groundDistance * 2f, groundMask);

    return raycastHit;
}

まず、戻り値をboolからRaycastHitという型に変えます。これはUnityが衝突について様々な情報をまとめたクラスです。SphereCastでRaycastHitのオブジェクトを使って詳細を返すように指定しますが、こういう時にC#の「out」キーワードを使います。これは、引数として渡してオブジェクトがメソッドの実行によって変わる可能性があることをしめします。SphereCastが返すboolは使わずに代わりにRaycastHitを返します。

RaycastHit CheckForGround()
{
    float extent = Mathf.Max(0, capsuleCollider.height * 0.5f - capsuleCollider.radius);
    Vector3 origin = transform.TransformPoint(capsuleCollider.center + Vector3.down * extent) + Vector3.up * groundDistance;

    RaycastHit hitInfo;
    Ray sphereCastRay = new Ray(origin, Vector3.down);
    Physics.SphereCast(sphereCastRay, capsuleCollider.radius, out hitInfo, groundDistance * 2f, groundMask);
    return hitInfo;
}

RaycastHitの情報を記憶したいのでCharacterクラスに以下のプライベートプロパティを追加します。

Rigidbody groundRigidbody = null; // 地面のRigidbody(あれば)
Vector3 groundNormal = Vector3.up; // 地面の法線(ノーマル)
Vector3 groundContactPoint = Vector3.zero; // 地面と接触している点の座標

FixedUpdateでRaycastHitの情報をこれらのプロパティで記憶しておきます。

void FixedUpdate()
{
    RaycastHit hitInfo = CheckForGround();

    // 衝突していない場合RaycastHitクラスのcolliderがnull
    isOnGround = hitInfo.collider != null;

    if (isOnGround)
    {
        groundNormal = hitInfo.normal;
        groundContactPoint = hitInfo.point;
        groundRigidbody = hitInfo.rigidbody;
    }
    else
    {
        // 地面に立っていないのでデフォルトの値にする
        groundNormal = Vector3.up;
        groundRigidbody = null;
        // 地面と接触している点をカプセルコライダの一番下の点にする
        groundContactPoint = transform.TransformPoint(capsuleCollider.center + Vector3.down * capsuleCollider.height * 0.5f);
    }

これで地面の傾斜だけでなく、地面と接触している点の座標と(あれば)地面に付いているRigibodyコンポーネントも取得できます。傾斜は法線(ノーマル)として表されています。ゲームも含めて、CGでは面の向きを法線で表します。法線は面から直立するように長さが1のベクトルです。

法線の重要な特徴は面の表面のすべての座標と法線ベクトルが必ず90度の角度を形成する事です。この特徴が様々な計算を楽にしてくれます。

カメラの視点に対する移動を計算する

カメラ目線で移動を制御する時に、プレーヤーが「右」を押したらキャラクターが画面の右側に向かって移動して、プレーヤーが「上」を押せば、キャラクターがカメラの向いている方向に向かうのが普通です。「カメラの右」と「カメラの前」というベクトルが必要なので、インスペクターで使用するカメラを設定できるパラメータを追加します。

public class Character : MonoBehaviour
{
    public Camera playerCamera = null; // プレーヤーのカメラ

Unityでこれをちゃんと設定しておきましょう。

カメラの参照があれば、カメラに対する「前」と「右」の方向を取得する事ができます。以下の画像の青矢印と赤矢印です。

void ApplyMotion()
{
    // 右と前のデフォルトを設定
    Vector3 movementRight = Vector3.right;
    Vector3 movementForward = Vector3.forward;

    if (playerCamera != null)
    {
        // カメラに対して前と右の方向を取得
        Vector3 cameraRight = playerCamera.transform.right;
        Vector3 cameraForward = playerCamera.transform.forward;
    }

    rb.AddForce(new Vector3(movementInput.x, 0, movementInput.y) * acceleration, ForceMode.Acceleration);
}

これで問題は解決しているように見えるかも知れませんが、そうでもないです。地面の傾斜に対して動きたいならカメラの前と右ベクトルを地面に射影しなければなりません。数学の出番です。

ベクトルの射影

ここでいうベクトルを面に射影するというのは、俗に説明すれば、面の上にベクトル(線)を引いて、法線の真上からその面を見れば射影するベクトルと引いた線がぴったりと重ねあうように見えます。

緑のベクトルが法線となっている面に青のベクトルを射影すると…
ピンクのベクトルになります。
法線の真上から見ると青のベクトルと緑のベクトルがぴったりと重なっています。

この例では青いベクトルがカメラの「前」なら、キャラクターが前進する実際の方向はピンクの矢印になります。そうでないと、キャラクターが地面に沿って歩かずに変に飛び上がったりしてしまいます。

ベクトルの射影は他に出番があるのでメソッドを用意しておきます。

Vector3 ProjectOnPlane(Vector3 vector, Vector3 normal)
{
    return vector;
}

上の画像でいえば、青い矢印と緑の矢印を渡して、ピンクの矢印を返してくれるメソッドです。

ベクトル演算を勉強していないとこの問題を三角関数を使うことを思いつくかも知れません。これは正気を失うのでやめておきましょう。代わりにベクトルの外積を使いましょう。

ベクトルの外積(ベクトル積、クロス積とも呼ばれています)は2つのベクトルからもう一つのベクトルを返す計算です。3つのベクトルの角度が90度になります。

    \[\vec{c}=\vec{a}\times\vec{b}\]

緑のベクトルと青のベクトルの外積は赤のベクトルです。

では、上の画像の緑のベクトルが地面の法線で、緑の面が地面だとして、青のベクトルがカメラの向いている方向にします。そうすると、地面の法線の真上から見て地面上カメラのベクトルがどれほど伸びているかを知りたいです。

外積を計算して、地面上のベクトルを取得します。

このベクトルは法線ベクトルとカメラ方向ベクトルとも90度の角度を構成します。射影するには90度回転させてカメラ方向ベクトルと合わせる必要があります。この新しいベクトルと法線でもう一度外積を計算すれば求められます。

ピンクのベクトルが最初の青のベクトルの射影になります。

説明は長かったですが、全体の数式はこうなります。

    \[\vec{projection}=\vec{normal}\times(\vec{vector}\times\vec{normal})\]

Unityにベクトルの外積を計算してくれるメソッドが用意されているので、簡単にできます。

Vector3 ProjectOnPlane(Vector3 vector, Vector3 normal)
{
    return Vector3.Cross(normal, Vector3.Cross(vector, normal));
}

外積は非可換です。つまり、計算の順番によって結果が違います。上のメソッドで引数に渡している値の順番を変えないように書きましょう。

ProjectOnPlaneがあれば、やっとカメラの視線も地面の傾きにも対応する動きが実装できます。

void ApplyMotion()
{
    // 右と前のデフォルトを設定
    Vector3 movementRight = Vector3.right;
    Vector3 movementForward = Vector3.forward;

    if (playerCamera != null)
    {
        // カメラに対して前と右の方向を取得
        Vector3 cameraRight = playerCamera.transform.right;
        Vector3 cameraForward = playerCamera.transform.forward;

        // 地面に対する方向を計算する
        movementRight = ProjectOnPlane(cameraRight, groundNormal).normalized;
        movementForward = ProjectOnPlane(cameraForward, groundNormal).normalized;
    }

    // プレーヤー入力と地面の右と前を組み合わせる
    Vector3 movement = movementRight * movementInput.x + movementForward * movementInput.y;

    // 加速度を設定する
    rb.AddForce(movement * acceleration, ForceMode.Acceleration);
}

これで試してみるとカメラを動かしたり、回転させたり、地面の傾斜を変えてみてもキャラクターが正しく動きます。

キャラクターの向きを変える

キャラクターの細かな演出はアニメーションで行いますが、向きはスクリプトから変えます。最も簡単な方法はこうです。

// プレーヤーの向きを変える
Vector3 rotateTarget = new Vector3(movement.x, 0, movement.z);
if (rotateTarget.magnitude > 0.1f)
{
    transform.LookAt(transform.position + rotateTarget);
}

キャラクターは常に直立しているので、向きを変える時に移動方向からy軸の成分をなくします。そして、動きが少ない時、静止している時に向きを変えないのもポイントです。この処理を加速を設定する前に挿入します。キャラクターを走らせてみると、確かに向きが変わりますが、不自然ですね。

向きを滑らかに変えたい場合、回転を徐々に変えてくれる補完を使います。Unityではオブジェクトの回転を四元数(Quaternion)として扱います。現在の角度と向かおうとしている角度の中間の角度に回転して、少しずつ正しい向きに近づけます。Unityなどでその補完をLerp(Liniear Interpolation)と言います。動きの滑らかさを調整するパラメータをクラスの冒頭に追加します。

public float turnSmoothing = 0.8f;

そして、回転のコードを修正します。

// プレーヤーの向きを変える
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);
}

回転がよくなります。

速度を制限する

速度を制限する時に2つの問題があります。

  1. 地面の向きによって速度を計算する必要がある
  2. 地面も動いているかも知れない

まず、1番の問題から。Rigidbodyのvelocityプロパティを見れば、キャラクターの速度が分かります。しかし、上下動いているかも知れません。例えば、ジャンプから着地した瞬間、地面に立ちながらvelocityは落下のままです。ここも正しく地面の傾斜を計算に入れる必要があります。幸いに、射影のメソッドをここでも活用できます。地面に対する移動速度は地面に射影したvelocityです。

Rigidbodyに力を加えるところをこういう風に修正します。

Vector3 groundVelocity = ProjectOnPlane(rb.velocity, groundNormal);

if (groundVelocity.magnitude < runSpeed)
{
    // 加速度を設定する
    rb.AddForce(movement * acceleration, ForceMode.Acceleration);
}

これだけです。ベクトルの外積、ありがとう!

でも、あと一歩です。地面が動いた場合はどうすればいいでしょうか。まず、リフトなど、移動しながら、キャラクターが上に乗れる物に必ずRigidbodyを付ける必要があります。そして、動かす時に必ずRigidbodyを通して動かす事。そうすれば、地面のRigidbodyの速度をキャラクターの速度から引けば、正しくリフトに乗っても速度が制限されます。

Vector3 velocity = rb.velocity;
if (groundRigidbody != null)
{
    velocity -= groundRigidbody.velocity;
}
Vector3 groundVelocity = ProjectOnPlane(velocity, groundNormal);

if (groundVelocity.magnitude < runSpeed)
{
    // 加速度を設定する
    rb.AddForce(movement * acceleration, ForceMode.Acceleration);
}

最後の最後に、地面の傾斜が急すぎて登れない場合、加速しないように修正を加えます。

float groundAngle = 90f - Mathf.Asin(groundNormal.y) * 180f / Mathf.PI;

if (groundVelocity.magnitude < runSpeed && groundAngle <= maxGroundAngle)
{
    // 加速度を設定する
    rb.AddForce(movement * acceleration, ForceMode.Acceleration);
}

Mathf.Asinはラジアンの角度を返すので、度に変換してから閾値と比較します。これでいいと思ったら、実は少しややこしいのはこれです:

このシチュエーションは足元は平な面に見えますが、地面検出のSphereCastは右側の斜面に先に衝突してしまい、実は地面の傾斜は登れない坂になっています。この状態なら全く身動きが取れなくなってしまいます。このため、条件をもう少し工夫します。地面の傾斜が閾値より低い場合、または坂を下っている場合、動いてもいいということです。

bool movingDownhill = movement.y <= 0f;
if (groundAngle <= maxGroundAngle || movingDownhill)
{
    if (groundVelocity.magnitude < runSpeed)
    {
        // 加速度を設定する
        rb.AddForce(movement * acceleration, ForceMode.Acceleration);
    }
}

これでやっとキャラクターの動きの実装が一旦終わります。

2 Comments

  • unity starter says:

    キャラクターが動かず、その場で入力方向に回転するのは何故でしょうか?
    ご教授お願い致します。

  • unity starter says:

    キャラクターがその場で入力方向に回転してしまうのですが何故ですか?

Leave a Reply

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

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