Blog に戻る
『Astral Days!』の企画・開発秘話

[AD] 006. (0.1.0) Movement Foundation System - 2 (地面判定と傾斜角認識)

『アスデイ』の地面判定と傾斜角認識

前回の作業では、カメラ方向を基準にしたWASDによる水平移動と加速・減速を実装した。入力から目標速度を作り、現在速度を目標速度へ近づけたうえで、実際の位置に反映する構造であった。


しかし、まだキャラクターは自分がどこに立っているのかを認識できない。足元に地面があるのか、空中に浮いているのか、あるいは歩けないほど急な面の上にいるのかを区別できない状態である。


そこで今回は、基本移動の実装に続いて、現在キャラクターの下に有効な地面があるかを判定する Ground Detection を実装することにした。

今回の作業目標

今回の目標は、単に下に何かが存在するかを確認することではない。検出された表面がキャラクターの立つことのできる地面なのかを判定し、その後の移動システムで使用する情報を用意することである。


具体的な目標は以下の通りだ。

  • キャラクターの足元を検査し、地面から外れた場合は接地状態を解除すること
  • 検出した表面の傾斜角を計算し、許容角度以下の表面だけを地面として認識すること
  • 接地状態・法線・接触点・傾斜角を外部から参照できるようにし、検査範囲を視覚的に確認できるようにすること

ただし、地面判定と実際の衝突処理は別の作業 である。今回は地面の存在と方向を判定するだけで、キャラクターを落下させたり、地面の上に固定したりはしない。重力と落下中の貫通防止については、次の段階で接続する予定である。

地面判定方式の選択

まずはRaycastから始める

最初は、キャラクターの下方向へRaycastを飛ばす方法から始めた。開始地点から下方向へ一定距離の線を検査し、地面のColliderに当たれば true、当たらなければ false と判定する方法である。

isGrounded = Physics.Raycast(rayOrigin, Vector3.down, rayLength, _groundMask, QueryTriggerInteraction.Ignore);

この方法は、検査位置と方向を理解しやすい。まずは平地の上と空中で結果が変化するかを確認しながら、地面判定の基本的な流れを把握した。


しかし、中央の一本の線だけを検査するという制限があった。プラットフォームの端では、キャラクター自身の幅と中央Rayの位置が、それぞれ異なる状況を示す場合がある。カプセルの一部が足場の上に残っていても、中央Rayはすでに空中を向いている可能性があるのだ。


そこで検査範囲を一本の線から、半径を持つ球体の移動経路 へ広げることにした。

SphereCastで検査範囲を広げる

SphereCast は、球体を特定の方向へ移動させたとき、その経路上でColliderに当たるかを検査する。実際に球体のGameObjectを生成するのではなく、球体形状のPhysics Queryを実行するものである。


現在、プレイヤーの衝突形状には CapsuleCollider を使用している。そのため、カプセル下部の半球を基準に検査用の球体を配置すれば、中央Ray一本だけを使う場合よりも、キャラクターの足元周辺を考慮しやすいと判断した。

ただし、SphereCastへ変更したからといって、すべての端の問題が自動的に解決されるわけではない。今後は傾斜角の条件も合わせて適用し、実際のテスト結果を確認する必要がある。

SphereCastの位置と距離を計算する

CapsuleColliderの情報を取得する

地面判定は CollisionDetector が担当するようにした。このスクリプトは、同じGameObjectにある CapsuleCollider を使用する。

[RequireComponent(typeof(CapsuleCollider))]

RequireComponent は、スクリプトを追加するときに必要なコンポーネントも一緒に追加されるよう、依存関係を指定するためのものである。

実際の参照は Awake() で取得する。

private void Awake()
{
    _collider = GetComponent<CapsuleCollider>();
}

今回のコードにおける形状計算は、Y軸方向のカプセルであり、親を含めたワールドスケールが (1, 1, 1) であること を前提としている。カプセルの高さと半径は一般的なカプセル形状を構成する値として使用しており、別の軸方向やスケールに対応する汎用的な計算はまだ追加していない。

カプセル最下端と下部半球の中心を区別する

最初に位置を計算するとき、最も混乱した部分は、カプセルの最下点と下側半球の中心は異なる という点であった。

カプセル中央から最下端までの距離は、高さの半分である。しかし、SphereCastの開始地点には球体の中心を指定する必要がある。そのため、最下端から半径分だけ上にある位置が必要となる。

中心 → カプセル最下端までの距離 = height / 2
中心 → 下部半球の中心までの距離 = height / 2 - radius

これをコードにすると、次のようになる。

Vector3 localSphereCenter = _collider.center + Vector3.down * (_collider.height * 0.5f - _collider.radius);

例えば、高さが 2、半径が 0.5、中心が (0, 0, 0) の場合、下部半球の中心はローカルY座標 -0.5 となる。カプセルの最下端である -1 とは区別しなければならない。

最初に書いたコードでは、height 全体の分だけ下げてしまったり、下方向へさらにoffsetを加えたりして、検査開始地点がカプセルの下まで移動してしまったことがあった。最終的には、中心から高さの半分だけ下がり、そこから半径分だけ上がる という関係に整理した。

ローカル座標をワールド座標へ変換する

CapsuleCollider.centerheightradius は、オブジェクトのローカル空間を基準とした値である。

そのため、先ほど求めた localSphereCenter も、まだキャラクター内部を基準とした位置である。これを実際のゲーム空間で使用できる位置へ変換する必要がある。

Vector3 worldSphereCenter = transform.TransformPoint(localSphereCenter);

TransformPoint() はローカル位置をワールド位置へ変換し、Transformのスケールも変換に反映する。

例えば、回転やスケールの変化がなく、プレイヤーのワールドY座標が 10 である場合、ローカルY座標が -0.5 の点は、ワールドY座標 9.5 に位置する。


途中では、すでにワールド座標である transform.position を計算に使用したあと、再び TransformPoint() に渡してしまったこともあった。この場合、ワールド座標をローカル座標であるかのように、もう一度変換してしまう。

最終的に、座標計算は次の順序に統一した。

Colliderのローカル情報 → 下部半球の中心を計算 → ワールド座標へ変換

検査開始地点を上へずらす

下部半球の中心を求めたあとは、検査開始地点を少し上へ移動させた。

Vector3 sphereOrigin = worldSphereCenter + transform.up * groundCheckOffset;

groundCheckOffset は、下部半球の中心から検査用の球体をどれだけ上へずらして開始するかを決める値である。今回のテストでは 0.1f を使用した。

検査方向には、その反対となる -transform.up を使用する。開始地点のoffsetと検査方向を同じ基準軸にそろえた形である。

検査半径と実際の半径との差

検査用の球体は、カプセル半径の95%の大きさに設定した。

float sphereRadius = _collider.radius * 0.95f;

これは、検査用の球体をカプセルより少し小さくするという現在の実装上の選択である。0.95f がすべてのキャラクターや地形に通用する正解という意味ではない。

球体を小さくすると、中心が同じであっても、球体の最下点は実際のカプセルの最下点より上に位置することになる。この差を計算した値が radiusGap である。

float radiusGap = _collider.radius - sphereRadius;

例えば、実際の半径が 0.5 なら、検査半径は 0.475、その差は 0.025 となる。

最終的な検査距離を計算する

最後に、球体を下方向へ移動させる距離を決める。

float castDistance = groundCheckOffset + radiusGap + groundCheckDistance;

この式は、三つの要素で構成されている。

groundCheckOffset   → 開始地点を上へ移動させた距離
radiusGap           → 検査用の球体を小さくしたことで生じる差
groundCheckDistance → 実際のカプセルの足元より下を追加で検査する距離

例えば、groundCheckOffset = 0.1radiusGap = 0.025groundCheckDistance = 0.1 であれば、合計の検査距離は 0.225 となる。

ここで castDistance は、球体の中心が移動する距離である。先ほどの前提で平らな地面を検査した場合、終点にある球体の最下端は、実際のカプセルの足元から groundCheckDistance 分だけ下まで到達する。


開始地点を上げたのであれば、その分の距離も検査距離に含めなければならない。 Raycastの段階では、offsetだけを追加して検査距離をそのままにしていたため、足元より下ではなく、足元までしか検査できていない問題があった。SphereCastでは、半径の差まで合わせて考慮した。

実際の地面検査と傾斜角判定

SphereCastの結果を受け取る

計算した値を利用して、実際の検査を行った。

bool hasGroundHit = Physics.SphereCast(sphereOrigin, sphereRadius, -transform.up, out RaycastHit hit, castDistance, _groundMask, QueryTriggerInteraction.Ignore);

hasGroundHit は、検査対象のColliderに当たったかどうかを表す。検査に成功した場合、hit に接触情報が格納される。

最初は結果をそのまま isGrounded に保存していたが、現在は二つを分離している。

hasGroundHit → 検査経路上で何かに当たったか?
isGrounded   → その結果を有効な地面として認識するか?

この区別が必要なのは、何かに当たったからといって、必ずしも歩くことのできる地面とは限らない からである。

LayerMaskとTriggerを除外する

_groundMask は、検査するレイヤーを選択するためのものだ。プレイヤー自身のColliderなど、地面判定の対象ではないものは、このマスクから除外する。

一方、QueryTriggerInteraction.Ignore はTrigger Colliderを検査対象から除外する。地面と同じレイヤーにイベント用のTriggerが存在していたとしても、それを地面として扱わないよう明示している。

LayerMask → どのレイヤーを検査するか?
Ignore    → その中からTrigger Colliderを除外する

out _ ではなくRaycastHitを受け取る理由

接触したかどうかだけが必要だった段階では、out _ を使用していた。_ は、その出力値を使用しないことを示すdiscard表現である。

しかし、傾斜角を判定するには接触情報が必要となるため、次のように変更した。

out RaycastHit hit

今回の実装で使用する値は、hit.normalhit.point である。それぞれ、検出結果の法線と接触位置を取得するために使用する。

法線と傾斜角は別の値である

ここでいう normal は「普通」という意味ではなく、法線ベクトル を意味する。法線は表面に対して垂直な方向であり、角度を表す数値ではない。

平らな地面の上向き法線は (0, 1, 0) 方向である。キャラクターも真っすぐ立っている場合、キャラクターの上方向と同じになるため、二つのベクトル間の角度は0°となる。

現在の実装では、次の式を使って検出した法線とキャラクターの上方向との間の角度を計算している。

groundAngle = Vector3.Angle(transform.up, hit.normal);

Vector3.Angle() は、二つのベクトル間の小さい方の角度を度単位で返す。

groundNormal → どの方向を向いているか?        Vector3
groundAngle  → 基準となる上方向と何度違うか?  float

現在は、プレイヤーのルートが真っすぐ立っていることを前提として、この値を地面の傾斜角として扱っている。

許容傾斜角以下の表面だけを地面として認識する

今回のテストでは、許容傾斜角を50°に設定した。

[SerializeField] private float maxGroundAngle = 50f;

判定は次の関係に従う。

25° ≤ 50° → 有効な地面
55° > 50° → 有効な地面ではない

そのため、検査に成功した場合だけ角度を計算し、許容範囲内であれば接地状態と情報を保存するようにした。

if (hasGroundHit)
{
    groundAngle = Vector3.Angle(transform.up, hit.normal);

    if (groundAngle <= maxGroundAngle)
    {
        groundNormal = hit.normal;
        groundPoint = hit.point;
        isGrounded = true;
    }
}

ここで行っているのは、あくまで この表面を地面として認識するかを決めること である。急な傾斜で滑らせたり、斜面に沿って登れるよう移動方向を変えたりする機能は、まだ実装していない。

状態初期化と条件文の順序

検査ごとに以前の情報を初期化する

前フレームの地面情報がそのまま残らないよう、GroundCheck() の開始時に基本状態へ初期化するようにした。

isGrounded = false;
groundAngle = -1f;
groundNormal = Vector3.zero;
groundPoint = Vector3.zero;

基本的には「有効な地面が見つからなかった」と仮定し、検査に成功した場合だけ必要な値を上書きする方式である。

こうすることで、検査に失敗した場合や急な表面に当たった場合ごとに、else 内で地面情報を何度も消去する必要がなくなる。

分離したifによって空中でもGroundedになる問題

実装中には、ヒット判定と傾斜角判定を、それぞれ独立した if として書いていたこともあった。

// 当時、誤って書いていた構造
if (hasGroundHit)
    groundAngle = Vector3.Angle(transform.up, hit.normal);

if (groundAngle <= maxGroundAngle)
{
    isGrounded = true;
}

当時、groundAngle の初期値は 0f であった。そのため、何にも当たらず角度計算を飛ばしたとしても、二つ目の条件では 0 <= 50 が成立してしまう。つまり、空中でも接地状態が有効になる可能性のある構造だった。

解決方法は、二つ目の条件を一つ目の条件の中へ入れることであった。角度判定は、接触情報が有効であるという前提があって初めて意味を持つため、その実行順序もコード上で表現する必要がある。

初期値を -1f に変更するだけでは、この問題は解決しない。-1 <= 50 も同じく真になるからである。「ヒットしたか」という前提条件を必ず確認しなければならない。

groundAngleの初期値を-1に変更した理由

条件文を修正したあとも、何にも当たっていない状態と平地に当たった状態の両方が groundAngle = 0 と表示されるため、区別しにくいという問題が残っていた。

そこで最後の整理では、初期値を -1f に変更した。

現在のコードで、検査直後の状態をまとめると次のようになる。

検査結果IsGroundedGroundAngleGroundNormal / GroundPoint
何にも当たらないfalse-1zero
25°の表面にヒット、許容角度50°true約25ヒット情報を保存
55°の表面にヒット、許容角度50°false約55zero

ここで GroundAngle は、有効な地面だけの角度ではなく、今回の検査でヒットした表面の角度 であることに注意する必要がある。55°の表面は地面として認識されなくても、なぜ判定から外れたのかを確認できるよう、角度自体は残している。

一方、GroundNormalGroundPoint は、有効な地面である場合にだけ保存する。外部からこの二つの値を使用するときは、先に IsGrounded を確認する必要がある。

結果を読み取り専用で公開する

その後、移動コードから検査結果を読み取れるよう、プロパティを追加した。

public bool IsGrounded => isGrounded;
public Vector3 GroundNormal => groundNormal;
public Vector3 GroundPoint => groundPoint;
public float GroundAngle => groundAngle;

現在の状態を保存するフィールドは private のままにし、外部からはgetterを通して読み取るようにした。まだ別のMovement Runtimeとの接続が完了したわけではないが、今後利用するための接点は用意できた状態である。

デバッグとテスト

検査範囲と法線を可視化する

Raycastの段階では、Debug.DrawLine() を使って開始地点と終点を確認した。しかし、SphereCastは中心線だけでは球体の大きさを確認しにくかった。

そこで、OnDrawGizmosSelected() で開始時の球体、最大到達地点の球体、そして中心の移動経路を描画するようにした。この関数は、オブジェクトを選択したときにGizmoを表示する用途に使用できる。

Gizmos.DrawWireSphere(sphereOrigin, sphereRadius);
Gizmos.DrawLine(sphereOrigin, sphereEnd);
Gizmos.DrawWireSphere(sphereEnd, sphereRadius);

ヒットした場合は、接触点と法線も合わせて表示した。

Gizmos.DrawSphere(hit.point, 0.03f);
Gizmos.DrawLine(hit.point, hit.point + hit.normal * 0.5f);

現在のデバッグ表示が意味する内容は、次の通りである。

灰色の球体と中心線 → 検査開始位置と最大到達範囲
緑色の点と線       → ヒットし、許容傾斜角以下だった表面
赤色の点と線       → ヒットしたが、許容傾斜角を超えた表面
点と法線なし       → そのGizmo検査ではヒットしていない

Gizmo側でもSphereCastを別に実行するようにした。これにより、地面として認識されなかった急な表面の法線も確認できる。

ただし、この表示は Update() で保存した値をそのまま描画しているわけではなく、Gizmoを描画する時点で別に行った検査結果 である。実際の isGrounded の状態はInspectorと合わせて確認した。

傾斜角が変化していないように見えたとき

最初の傾斜テストでは、Inspector上の角度が変化していないように見えたため、実際に何へヒットしているのかを確認するログを追加した。

Debug.Log($"Hit: {hit.collider.name}, Normal: {hit.normal}, Angle: {groundAngle}");

テスト中に確認したログは次の通りであった。

Hit: Cube (6), Normal: (0.42, 0.91, 0.00), Angle: 25
Hit: Cube (7), Normal: (0.82, 0.57, 0.00), Angle: 55

25°の表面では有効な地面として判定され、55°の表面では許容角度50°を超えたため、赤色で表示された。

この過程を通して、角度計算だけを疑うのではなく、検査そのものが成功したか、どのColliderに当たったか、どの法線が返されたか を分けて確認することが重要だと分かった。

ログは検証後に削除し、今後も使用するGizmoは残すことにした。

平地・端・壁・天井のテスト

まだ重力がない状態であったため、Scene上でキャラクターを移動させたり、Y座標を調整したりしながら検査した。

テスト観察結果
平地の上有効な地面として認識
25°の傾斜面の上傾斜角を読み取り、有効な地面として認識
55°の傾斜面の上ヒットしたが、接地状態はfalse
プラットフォームの端テストした範囲では過度なちらつきなく切り替わった
横に壁だけがある配置この配置ではGroundの誤認識なし
頭上に天井だけがある配置Groundの誤認識なし
空中からY座標をゆっくり下げる地面に近づいた時点で接地状態へ切り替わった

壁のテストでログが出なかった場合は、SphereCastが壁にヒットしていなかったためである。そのため、これを「壁の法線90°を計測した」という結果と同じものとして扱うことはしなかった。

また、現在の接地判定には groundCheckDistance 分の余裕がある。IsGroundedtrue だからといって、実際のカプセルがすでに地面へ正確に接触しているという意味ではない。

deltaTimeを使っていても低FPSテストが必要な理由

水平移動では Time.deltaTime を掛けているため、最初は低FPSでも大きな問題はないだろうと考えていた。

しかし、一定の速度を前提としたとしても、1秒あたりの移動距離とフレーム間の検査間隔は別の問題である。

速度5m/sの場合
60FPS → 1フレームあたり約0.083m移動
30FPS → 1フレームあたり約0.167m移動
15FPS → 1フレームあたり約0.333m移動

現在のコードは、Update() ごとにその瞬間の位置から地面を検査する。そのため、移動時間を反映しているだけでは、二回の検査の間に通過したすべての位置まで検査できるわけではない。

これを確認するため、一時的に次の設定を追加した。

QualitySettings.vSyncCount = 0;
Application.targetFrameRate = 15;

デスクトップでは、VSyncが有効な場合に targetFrameRate が無視される可能性があるため、合わせて設定した。Editorにおける targetFrameRate の適用対象はGameビューである。

15FPSを目標に制限したテストでも、地面認識に目立った問題は確認できなかった。ただし、これは 今回テストした接地判定範囲での結果 であり、実際の高速落下や、あらゆるフレームスパイクに対する検証まで完了したという意味ではない。

最終コードには、テスト用のフレーム制限を残していない。

今回の段階でのコード

最後に決めた groundAngle = -1f、一時ログの削除、そして書式整理を反映したコードは次の通りである。これまでの判定フローとGizmo側の別検査は維持している。

using UnityEngine;

[RequireComponent(typeof(CapsuleCollider))]
public class CollisionDetector : MonoBehaviour
{
    [Header("Ground Check")]
    [SerializeField] private float groundCheckOffset = 0.1f;
    [SerializeField] private float groundCheckDistance = 0.1f;
    [SerializeField] private LayerMask _groundMask;

    private CapsuleCollider _collider;

    [SerializeField] private bool isGrounded;
    [SerializeField] private float groundAngle;
    [SerializeField] private Vector3 groundNormal;
    [SerializeField] private Vector3 groundPoint;
    [SerializeField] private float maxGroundAngle = 50f;

    public bool IsGrounded => isGrounded;
    public Vector3 GroundNormal => groundNormal;
    public Vector3 GroundPoint => groundPoint;
    public float GroundAngle => groundAngle;

    private void Awake()
    {
        _collider = GetComponent<CapsuleCollider>();
    }

    private void Update()
    {
        GroundCheck();
    }

    private void GroundCheck()
    {
        isGrounded = false;
        groundAngle = -1f;
        groundNormal = Vector3.zero;
        groundPoint = Vector3.zero;

        bool hasGroundHit;

        Vector3 localSphereCenter = _collider.center + Vector3.down * (_collider.height * 0.5f - _collider.radius);
        Vector3 worldSphereCenter = transform.TransformPoint(localSphereCenter);
        Vector3 sphereOrigin = worldSphereCenter + transform.up * groundCheckOffset;

        float sphereRadius = _collider.radius * 0.95f;
        float radiusGap = _collider.radius - sphereRadius;
        float castDistance = groundCheckOffset + radiusGap + groundCheckDistance;

        hasGroundHit = Physics.SphereCast(sphereOrigin, sphereRadius, -transform.up, out RaycastHit hit, castDistance, _groundMask, QueryTriggerInteraction.Ignore);

        if (hasGroundHit)
        {
            groundAngle = Vector3.Angle(transform.up, hit.normal);

            if (groundAngle <= maxGroundAngle)
            {
                groundNormal = hit.normal;
                groundPoint = hit.point;
                isGrounded = true;
            }
        }
    }

    private void OnDrawGizmosSelected()
    {
        CapsuleCollider col = _collider;

        if (col == null)
            col = GetComponent<CapsuleCollider>();

        if (col == null)
            return;

        float sphereRadius = col.radius * 0.95f;

        Vector3 localSphereCenter = col.center + Vector3.down * (col.height * 0.5f - col.radius);
        Vector3 worldSphereCenter = transform.TransformPoint(localSphereCenter);
        Vector3 sphereOrigin = worldSphereCenter + transform.up * groundCheckOffset;

        float radiusGap = col.radius - sphereRadius;
        float castDistance = groundCheckOffset + radiusGap + groundCheckDistance;

        Vector3 sphereEnd = sphereOrigin - transform.up * castDistance;

        bool hasHit = Physics.SphereCast(sphereOrigin, sphereRadius, -transform.up, out RaycastHit hit, castDistance, _groundMask, QueryTriggerInteraction.Ignore);

        Gizmos.color = Color.gray;
        Gizmos.DrawWireSphere(sphereOrigin, sphereRadius);
        Gizmos.DrawLine(sphereOrigin, sphereEnd);
        Gizmos.DrawWireSphere(sphereEnd, sphereRadius);

        if (!hasHit)
            return;

        float hitAngle = Vector3.Angle(transform.up, hit.normal);
        bool validGround = hitAngle <= maxGroundAngle;

        Gizmos.color = validGround ? Color.green : Color.red;
        Gizmos.DrawSphere(hit.point, 0.03f);
        Gizmos.DrawLine(hit.point, hit.point + hit.normal * 0.5f);
    }
}

現在の実装で残っている部分

判定結果と移動システムの接続

現在の CollisionDetector はQueryと傾斜角の解釈を同時に行い、自身の Update() 内で結果を更新している。

これは最終的なアーキテクチャまで実装した状態ではない。プロジェクトの設計では、Collision / Physics Backendが接触情報を提供し、Custom Movement Runtimeがその結果を解釈して共通の移動状態を管理するよう分けている。

そのため今後は、検査のタイミングを移動Tickの中で管理し、現在公開している結果を実際の移動計算へ接続する作業が残っている。プロパティが存在することと、Runtime統合まで検証できたことは別の段階である。

地面判定だけでは落下時の貫通は解決しない

現在のコードは、下方向の短い範囲に有効な地面があるかを確認するだけである。次のフレームまでの移動経路全体を検査したり、移動量を衝突地点までに制限したりするコードは存在しない。

そのため、重力を追加したあとは、接地状態だけでなく、実際の垂直移動量と衝突解決についても合わせて検証する必要がある。以前、速い落下時に床をすり抜けた問題についても、今回の静的な地面判定テストだけで解決したとは言えない。


スケールについても、まだ検討事項が残っている。現在のコードは中心位置には TransformPoint() を使用しているが、検査半径には _collider.radius をそのまま使用している。先ほど明記した単位スケールの前提から外れる場合は、位置と大きさの両方を改めて計算する必要がある。

Git作業

今回の作業も feat/player-controller ブランチで引き続き進めた。

機能を追加する過程と、最後の整理作業は分けて考えることにした。最後に残ったdiffはGround Detection全体を新しく追加したものではなく、角度の初期値変更、一時ログの削除、書式整理であった。


特に groundAngle = 0f から groundAngle = -1f へ変更すると、外部から読み取る GroundAngle の値も変化する。そのため、単なるコード構造の整理として扱うのではなく、未検出状態の表現を明確にする修正として分類した。

最後の修正に対するコミットメッセージは、次のように決めた。

fix: Refine Ground Detection State

プロジェクトにおけるコミット分類は「今回作業した機能名」ではなく、以前のコミットと比較して、実際に何が変わったのか を基準に判断する必要があることも、改めて確認した。

また、実装上のチェックポイントをコミットすることと、Issue #2を最終的に完了扱いにすることは分けた。実際の落下と移動システムとの接続はまだ検証していないため、Gravity実装後に接地状態の切り替わりを再度確認することにした。

今回の実装で感じたこと

以前の水平移動実装では、入力・目標速度・現在速度を分けることが重要だった。

今回はそれと同じように、検出結果と、その結果が何を意味するのかを分けること が重要であった。

何かにヒットした
→ 検出結果の法線を読み取る
→ 基準方向との角度を計算する
→ 許容された傾斜かを判定する
→ 有効な地面である場合だけ状態と情報を保存する

最初は下方向へ一本の線を飛ばせば終わる機能だと思っていたが、実際には開始地点の座標系、球体の半径、offset、検査距離、条件文の実行順序まで、すべてがつながっていた。

特に hasGroundHitisGrounded を同じ値として扱わなくなったことが、今回の作業の核心であった。衝突情報が存在するという事実と、その情報を地面として使用できるという判断は別のものである。

また、デバッグでは結果がおかしく見えるからといって計算式を何度も変えるよりも、どの段階でどの値を得ているのかを確認する方が、原因を絞り込むうえで役立った。

結論

今回の作業によって、キャラクターが周囲のすべての物理状況を処理できるようになったわけではない。まだ自力で落下することも、傾斜面に沿って歩くこともできない。

その代わり、これで 足元に検査対象が存在するか、その表面を有効な地面として認識できるか、検出された地点と方向が何か を区別できるようになった。

これは今後のGravity、Jump、Slope処理で使用する基礎情報となる。


次の作業では、この判定結果を実際の移動コードへ接続して重力を実装し、落下と着地の過程でも接地状態が意図した通りに切り替わるかを確認する予定である。


0.1.0 - 2 バージョンテスト動画

지난 작업에서는 카메라 방향을 기준으로 한 WASD 수평 이동과 가속·감속을 구현하였다. 입력으로 목표 속도를 만들고, 현재 속도를 목표 속도에 접근시킨 뒤 실제 위치에 반영하는 구조였다.


하지만 아직 캐릭터는 자신이 어디에 서 있는지 알지 못한다. 발 아래에 바닥이 있는지, 허공에 떠 있는지, 혹은 걸을 수 없을 만큼 가파른 면 위에 있는지를 구분하지 못하는 상태다.


따라서 이번에는 기본 이동 구현에 이어, 현재 캐릭터 아래에 유효한 지면이 있는지 판단하는 Ground Detection을 구현하고자 한다.

이번 작업의 목표

이번 작업의 목표는 단순히 아래쪽에 무언가 존재하는지 확인하는 것이 아니다. 검출된 표면이 캐릭터가 서 있을 수 있는 바닥인지 판단하고, 이후 이동 시스템에서 사용할 정보를 마련하는 것이다.


구체적인 목표는 다음과 같다.

  • 캐릭터 발 아래를 검사하고, 지면을 벗어나면 접지 상태를 해제할 것
  • 검출된 표면의 경사각을 계산하고, 허용 각도 이하의 표면만 바닥으로 인정할 것
  • 접지 여부·법선·접촉점·경사각을 공개하고, 검사 범위를 시각적으로 확인할 수 있을 것

다만 지면 판정과 실제 충돌 처리는 서로 다른 작업이다. 이번에는 바닥의 존재와 방향을 판단할 뿐, 캐릭터를 떨어뜨리거나 바닥 위에 고정하지 않는다. 중력과 낙하 중 관통 방지는 다음 단계에서 연결할 예정이다.

지면 판정 방식의 선택

먼저 Raycast로 시작하기

처음에는 캐릭터 아래로 Raycast를 쏘는 방식으로 시작하였다. 시작점에서 아래 방향으로 일정 거리의 선을 검사하고, 바닥 Collider에 닿으면 true, 닿지 않으면 false로 판단하는 방식이다.

isGrounded = Physics.Raycast(rayOrigin, Vector3.down, rayLength, _groundMask, QueryTriggerInteraction.Ignore);

이 방식은 검사 위치와 방향을 이해하기 쉽다. 먼저 평지 위와 공중에서 결과가 달라지는지 확인하며, 지면 판정의 기본 흐름을 익혔다.


그러나 중앙의 선 하나만 검사한다는 한계가 있었다. 플랫폼 가장자리에서는 캐릭터의 폭과 중앙 Ray의 위치가 서로 다른 상황을 나타낼 수 있다. 캡슐의 일부가 발판 위에 걸쳐 있어도 중앙 Ray는 이미 허공을 향할 수 있는 것이다.


그래서 검사 범위를 선 하나에서, 반지름을 가진 구체의 이동 경로로 넓히기로 했다.

SphereCast로 검사 범위 넓히기

SphereCast는 구체를 특정 방향으로 이동시켰을 때, 그 경로에서 Collider를 만나는지 검사한다. 실제 구체 GameObject를 생성하는 것이 아니라, 구체 모양의 Physics Query를 수행하는 것이다.


현재 플레이어의 충돌 형태는 CapsuleCollider다. 따라서 캡슐의 하단 반구를 기준으로 검사 구체를 배치하면, 중앙 Ray 하나보다 캐릭터의 발 주변 영역을 고려하기 쉽다고 판단하였다.

다만 SphereCast로 바꾸었다고 모든 가장자리 문제가 자동으로 해결되는 것은 아니다. 이후 경사각 조건까지 함께 적용하고 실제 테스트 결과를 확인해야 한다.

SphereCast의 위치와 거리 계산

CapsuleCollider의 정보를 가져오기

지면 판정은 CollisionDetector에서 담당하도록 했다. 이 스크립트는 같은 GameObject의 CapsuleCollider를 사용한다.

[RequireComponent(typeof(CapsuleCollider))]

RequireComponent는 스크립트를 추가할 때 필요한 컴포넌트도 함께 추가되도록 의존성을 지정하는 용도다.

실제 참조는 Awake()에서 가져온다.

private void Awake()
{
    _collider = GetComponent<CapsuleCollider>();
}

이번 코드의 형상 계산은 Y축 방향 캡슐, 부모를 포함한 월드 스케일이 (1, 1, 1)인 조건을 전제로 한다. 캡슐의 높이와 반지름은 일반적인 캡슐 형태를 구성하는 값으로 사용하며, 다른 축이나 스케일을 지원하는 범용 계산은 아직 추가하지 않았다.

캡슐의 최하단과 하단 반구의 중심 구분하기

처음 위치를 계산할 때 가장 헷갈렸던 부분은 캡슐의 맨 아래점과 아래쪽 반구의 중심이 다르다는 것이었다.

캡슐 중앙에서 맨 아래까지의 거리는 높이의 절반이다. 그러나 SphereCast의 시작점에는 구체의 중심을 넣어야 한다. 따라서 맨 아래점에서 반지름만큼 위로 올라온 위치가 필요하다.

중앙 → 캡슐 최하단까지의 거리 = height / 2
중앙 → 하단 반구 중심까지의 거리 = height / 2 - radius

이를 코드로 옮기면 다음과 같다.

Vector3 localSphereCenter = _collider.center + Vector3.down * (_collider.height * 0.5f - _collider.radius);

예를 들어 높이가 2, 반지름이 0.5, 중심이 (0, 0, 0)이면 하단 반구의 중심은 로컬 Y 좌표 -0.5가 된다. 캡슐의 최하단인 -1과는 구분해야 한다.

처음 작성한 코드에서는 height 전체만큼 내려가거나, 아래 방향에 offset까지 더하면서 검사 시작점이 캡슐 아래로 내려간 적이 있었다. 최종적으로는 중심에서 절반 높이만큼 내려간 뒤, 반지름만큼 올라온다는 관계로 정리하였다.

로컬 좌표를 월드 좌표로 변환하기

CapsuleCollider.center, height, radius는 오브젝트의 로컬 공간을 기준으로 한 값이다.

따라서 방금 구한 localSphereCenter도 아직 캐릭터 내부를 기준으로 한 위치다. 이를 실제 게임 공간에서 사용할 위치로 변환해야 한다.

Vector3 worldSphereCenter = transform.TransformPoint(localSphereCenter);

TransformPoint()는 로컬 위치를 월드 위치로 바꾸며, Transform의 스케일도 변환에 반영한다.

예를 들어 회전과 스케일 변화 없이 플레이어의 월드 Y 좌표가 10이라면, 로컬 Y가 -0.5인 점은 월드 Y 9.5에 위치한다.


중간에는 이미 월드 좌표인 transform.position을 계산에 사용한 뒤, 다시 TransformPoint()에 넣기도 했다. 이 경우 월드 좌표를 로컬 좌표처럼 다시 변환하게 된다.

결국 좌표 계산은 다음 순서로 통일했다.

Collider의 로컬 정보 → 하단 반구 중심 계산 → 월드 좌표로 변환

검사 시작점을 위로 띄우기

하단 반구의 중심을 구한 뒤에는, 검사 시작점을 조금 위로 올렸다.

Vector3 sphereOrigin = worldSphereCenter + transform.up * groundCheckOffset;

groundCheckOffset은 하단 반구 중심에서 검사 구체를 얼마나 위로 띄워 시작할지 정하는 값이다. 이번 테스트에서는 0.1f를 사용했다.

검사 방향은 반대로 -transform.up을 사용한다. 시작점의 offset과 검사 방향을 같은 기준축으로 맞춘 것이다.

검사 반지름과 실제 반지름의 차이

검사용 구체는 캡슐 반지름의 95% 크기로 잡았다.

float sphereRadius = _collider.radius * 0.95f;

이는 검사 구체를 캡슐보다 조금 작게 두려는 현재 구현의 선택이다. 0.95f가 모든 캐릭터나 지형에 통용되는 정답이라는 뜻은 아니다.

구체를 작게 만들면 중심이 같더라도 구체의 맨 아래점은 실제 캡슐의 맨 아래점보다 위에 있게 된다. 이 차이를 계산한 값이 radiusGap이다.

float radiusGap = _collider.radius - sphereRadius;

예를 들어 실제 반지름이 0.5이면 검사 반지름은 0.475, 그 차이는 0.025가 된다.

최종 검사 거리 계산

마지막으로 구체가 아래로 이동할 거리를 정한다.

float castDistance = groundCheckOffset + radiusGap + groundCheckDistance;

이 식은 세 부분으로 구성된다.

groundCheckOffset   → 시작점을 위로 올린 거리
radiusGap           → 검사 구체를 줄이면서 생긴 차이
groundCheckDistance → 실제 캡슐 발끝 아래를 추가로 검사할 거리

예를 들어 groundCheckOffset = 0.1, radiusGap = 0.025, groundCheckDistance = 0.1이면 총 검사 거리는 0.225가 된다.

여기서 castDistance는 구체의 중심이 이동하는 거리다. 위 전제에서 평평한 바닥을 검사하면, 끝 구체의 최하단이 실제 캡슐 발끝보다 groundCheckDistance만큼 아래까지 도달한다.


시작점을 올렸다면 올린 거리도 검사 길이에 포함해야 한다. Raycast 단계에서는 offset만 추가하고 검사 길이를 그대로 두어, 발끝 아래가 아니라 발끝까지만 검사하는 문제가 있었다. SphereCast에서는 반지름 차이까지 함께 고려하였다.

실제 지면 검사와 경사각 판정

SphereCast 결과 받기

계산한 값들을 이용해 실제 검사를 수행했다.

bool hasGroundHit = Physics.SphereCast(sphereOrigin, sphereRadius, -transform.up, out RaycastHit hit, castDistance, _groundMask, QueryTriggerInteraction.Ignore);

hasGroundHit은 검사 대상 Collider를 맞혔는지 나타낸다. 성공한 경우 hit에 접촉 정보가 들어온다.

처음에는 결과를 곧바로 isGrounded에 저장했지만, 이제는 둘을 분리했다.

hasGroundHit → 검사 경로에서 무언가를 맞혔는가?
isGrounded  → 그 결과를 유효한 지면으로 인정하는가?

이 구분이 필요한 이유는, 무언가를 맞혔다고 해서 반드시 걸을 수 있는 바닥인 것은 아니기 때문이다.

LayerMask와 Trigger 제외

_groundMask는 검사할 레이어를 선택한다. 플레이어 자신의 Collider 등 지면 판정 대상이 아닌 것은 이 마스크에서 제외한다.

반면 QueryTriggerInteraction.Ignore는 Trigger Collider를 검사에서 제외한다. 지면과 같은 레이어에 이벤트용 Trigger가 있더라도, 이를 바닥으로 취급하지 않도록 명시한 것이다.

LayerMask → 어느 레이어를 검사할 것인가?
Ignore    → 그중 Trigger Collider는 제외한다.

out _ 대신 RaycastHit을 받는 이유

접촉 여부만 필요했던 단계에서는 out _를 사용했다. _는 해당 출력값을 사용하지 않겠다는 discard 표현이다.

그러나 경사각을 판단하려면 접촉 정보가 필요하므로 다음과 같이 변경했다.

out RaycastHit hit

이번 구현에서 사용하는 값은 hit.normalhit.point다. 각각 검출 결과의 법선과 접촉 위치를 읽는 데 사용한다.

법선과 경사각은 다른 값이다

여기서 normal은 ‘일반적인’이라는 뜻이 아니라 법선 벡터를 뜻한다. 법선은 표면에 수직인 방향이며, 각도를 나타내는 숫자가 아니다.

평평한 바닥의 위쪽 법선은 (0, 1, 0) 방향이다. 캐릭터 역시 똑바로 서 있다면 캐릭터의 위 방향과 같으므로 두 벡터 사이의 각도는 0°가 된다.

현재 구현에서는 다음 식으로 검출 법선과 캐릭터 위 방향 사이의 각도를 계산한다.

groundAngle = Vector3.Angle(transform.up, hit.normal);

Vector3.Angle()은 두 벡터 사이의 작은 각도를 도 단위로 반환한다.

groundNormal → 어느 방향을 향하는가?  Vector3
groundAngle  → 기준 위 방향과 몇 도 차이인가?  float

현재는 플레이어 루트가 똑바로 서 있다는 전제에서 이 값을 지면의 경사각으로 해석했다.

허용 경사각 이하만 바닥으로 인정하기

이번 테스트에서는 허용 경사각을 50°로 설정했다.

[SerializeField] private float maxGroundAngle = 50f;

판정은 다음 관계를 따른다.

25° ≤ 50° → 유효한 지면
55° > 50° → 유효한 지면이 아님

따라서 검사가 성공한 경우에만 각도를 계산하고, 허용 범위에 들어오면 접지 상태와 정보를 저장했다.

if (hasGroundHit)
{
    groundAngle = Vector3.Angle(transform.up, hit.normal);

    if (groundAngle <= maxGroundAngle)
    {
        groundNormal = hit.normal;
        groundPoint = hit.point;
        isGrounded = true;
    }
}

여기서 하는 일은 어디까지나 이 표면을 바닥으로 인정할지 결정하는 것이다. 가파른 경사에서 미끄러지게 하거나, 경사를 따라 올라가도록 이동 방향을 바꾸는 기능은 아직 없다.

상태 초기화와 조건문의 순서

매 검사마다 이전 정보를 초기화하기

이전 프레임의 바닥 정보가 그대로 남지 않도록, GroundCheck()가 시작될 때 기본 상태로 초기화했다.

isGrounded = false;
groundAngle = -1f;
groundNormal = Vector3.zero;
groundPoint = Vector3.zero;

기본적으로는 ‘유효한 지면을 찾지 못했다’고 가정하고, 검사에 성공한 경우 필요한 값만 덮어쓰는 방식이다.

이렇게 하면 검사 실패와 가파른 표면을 만난 경우마다 else에서 바닥 정보를 반복해서 지울 필요가 없다.

분리된 if 때문에 공중에서도 Grounded가 되는 문제

구현 중에는 적중 여부 검사와 경사각 검사를 각각 독립된 if로 작성하기도 했다.

// 당시 잘못 작성했던 구조
if (hasGroundHit)
    groundAngle = Vector3.Angle(transform.up, hit.normal);

if (groundAngle <= maxGroundAngle)
{
    isGrounded = true;
}

당시 groundAngle의 초기값은 0f였다. 따라서 아무것도 맞히지 못해 각도 계산을 건너뛰더라도, 두 번째 조건에서는 0 <= 50이 성립했다. 공중에서도 접지 상태가 켜질 수 있는 구조였던 것이다.

해결은 두 번째 조건을 첫 번째 조건 안에 넣는 것이었다. 각도 판정은 접촉 정보가 유효하다는 전제에서만 의미가 있으므로, 그 실행 순서도 코드에 표현해야 했다.

초기값을 -1f로 바꾸는 것만으로 이 문제가 해결되는 것은 아니다. -1 <= 50 역시 참이다. ‘적중했는가’라는 선행 조건을 반드시 확인해야 한다.

groundAngle의 초기값을 -1로 바꾼 이유

조건문을 수정한 뒤에도, 아무것도 맞히지 않은 상태와 평지를 맞힌 상태가 모두 groundAngle = 0으로 표시되는 점은 구분하기 어려웠다.

따라서 마지막 정리에서 초기값을 -1f로 변경했다.

현재 코드의 검사 직후 상태를 정리하면 다음과 같다.

검사 결과IsGroundedGroundAngleGroundNormal / GroundPoint
아무것도 맞히지 못함false-1zero
25° 표면 적중, 허용 각도 50°true약 25적중 정보 저장
55° 표면 적중, 허용 각도 50°false약 55zero

여기서 GroundAngle유효한 바닥만의 각도가 아니라, 이번 검사에서 맞힌 표면의 각도라는 점을 기억해야 한다. 55° 표면은 바닥으로 인정하지 않아도, 왜 탈락했는지 확인하기 위해 각도는 남는다.

반면 GroundNormalGroundPoint는 유효한 지면일 때만 저장한다. 외부에서 이 두 값을 사용할 때는 IsGrounded를 먼저 확인해야 한다.

결과를 읽기 전용으로 공개하기

이후 이동 코드가 검사 결과를 읽을 수 있도록 프로퍼티를 추가했다.

public bool IsGrounded => isGrounded;
public Vector3 GroundNormal => groundNormal;
public Vector3 GroundPoint => groundPoint;
public float GroundAngle => groundAngle;

현재 상태를 보관하는 필드는 private으로 두고, 외부에서는 getter를 통해 읽도록 했다. 아직 별도 Movement Runtime과의 연결이 완료된 것은 아니지만, 이후 사용할 접점은 마련한 상태다.

디버깅과 테스트

검사 범위와 법선 시각화

Raycast 단계에서는 Debug.DrawLine()으로 시작점과 끝점을 확인했다. 그러나 SphereCast는 중심선만으로 구체의 크기를 확인하기 어려웠다.

따라서 OnDrawGizmosSelected()에서 시작 구체, 최대 도달 구체, 중심 이동 경로를 그리도록 했다. 이 함수는 오브젝트를 선택했을 때 Gizmo를 표시하는 용도로 사용할 수 있다.

Gizmos.DrawWireSphere(sphereOrigin, sphereRadius);
Gizmos.DrawLine(sphereOrigin, sphereEnd);
Gizmos.DrawWireSphere(sphereEnd, sphereRadius);

적중한 경우에는 접촉점과 법선도 함께 표시했다.

Gizmos.DrawSphere(hit.point, 0.03f);
Gizmos.DrawLine(hit.point, hit.point + hit.normal * 0.5f);

현재 디버그 표시의 의미는 다음과 같다.

회색 구체와 중심선 → 검사 시작 위치와 최대 도달 범위
초록 점과 선      → 적중했고 허용 경사각 이하인 표면
빨간 점과 선      → 적중했지만 허용 경사각을 초과한 표면
점과 법선 없음    → 해당 Gizmo 검사에서 적중하지 않음

Gizmo 함수에서도 SphereCast를 별도로 수행하도록 했다. 이를 통해 바닥으로 인정하지 않은 가파른 표면의 법선도 확인할 수 있다.

다만 이 표시는 Update()에서 저장한 값을 그대로 그리는 것이 아니라 Gizmo를 그리는 시점의 별도 검사 결과다. 실제 isGrounded 상태는 Inspector와 함께 확인했다.

경사각이 변하지 않는 것처럼 보였을 때

처음 경사 테스트에서는 Inspector의 각도가 변하지 않는 것처럼 보여, 실제로 무엇을 맞히고 있는지 로그를 추가했다.

Debug.Log($"Hit: {hit.collider.name}, Normal: {hit.normal}, Angle: {groundAngle}");

테스트 중 확인한 로그는 다음과 같았다.

Hit: Cube (6), Normal: (0.42, 0.91, 0.00), Angle: 25
Hit: Cube (7), Normal: (0.82, 0.57, 0.00), Angle: 55

25° 표면에서는 유효한 지면으로 판정되었고, 55° 표면에서는 허용 각도 50°를 초과하여 빨간색으로 표시되었다.

이 과정을 통해 각도 계산만 의심하기보다, 검사가 성공했는지, 어떤 Collider를 맞혔는지, 어떤 법선이 반환되었는지를 나누어 확인하는 것이 중요하다는 점을 알 수 있었다.

로그는 검증 후 제거하고, 이후에도 사용할 Gizmo는 남겨두기로 했다.

평지·가장자리·벽·천장 테스트

중력이 없는 상태였으므로, Scene에서 캐릭터를 이동시키거나 Y 위치를 조절하며 검사했다.

테스트관찰 결과
평지 위유효한 지면으로 인식
25° 경사면 위경사각을 읽고 유효한 지면으로 인식
55° 경사면 위적중했지만 접지 상태는 false
플랫폼 가장자리테스트한 구간에서 과도한 깜빡임 없이 전환
옆에 벽만 있는 배치해당 배치에서 Ground 오인식 없음
머리 위에 천장만 있는 배치Ground 오인식 없음
공중에서 Y 위치를 천천히 내림바닥에 가까워졌을 때 접지 상태로 전환

벽 테스트에서 로그가 없었던 경우는 SphereCast가 벽을 맞히지 않았기 때문이다. 따라서 이것을 ‘벽의 법선 90°를 측정했다’는 결과와 동일하게 보지는 않았다.

또한 현재 접지에는 groundCheckDistance만큼의 여유가 있다. IsGroundedtrue라고 해서 실제 캡슐이 이미 바닥에 정확히 접촉했다는 뜻은 아니다.

deltaTime을 사용해도 저프레임 테스트가 필요한 이유

수평 이동에서는 Time.deltaTime을 곱하고 있으므로, 처음에는 저프레임에서도 별문제가 없을 것이라고 생각했다.

그러나 일정한 속도를 가정하더라도, 초당 이동 거리와 프레임 사이 검사 간격은 다른 문제다.

속도 5m/s 기준
60FPS → 한 프레임에 약 0.083m 이동
30FPS → 한 프레임에 약 0.167m 이동
15FPS → 한 프레임에 약 0.333m 이동

현재 코드는 Update()마다 그 순간의 위치에서 지면을 검사한다. 따라서 이동 시간을 반영하는 것만으로, 두 검사 사이에 지나간 모든 위치까지 검사하게 되는 것은 아니다.

이를 확인하기 위해 임시로 다음 설정을 추가했다.

QualitySettings.vSyncCount = 0;
Application.targetFrameRate = 15;

데스크톱에서는 VSync가 활성화된 경우 targetFrameRate가 무시될 수 있으므로 함께 설정하였다. Editor에서 targetFrameRate의 적용 대상은 Game 뷰다.

15FPS를 목표로 제한한 테스트에서도 지면 인식에 눈에 띄는 문제는 관찰하지 못했다. 다만 이는 현재 테스트한 접지 판정 범위의 결과이며, 실제 고속 낙하나 모든 프레임 스파이크에 대한 검증까지 끝났다는 의미는 아니다.

최종 코드에는 테스트용 프레임 제한을 남기지 않았다.

이번 단계의 코드

마지막에 정한 groundAngle = -1f, 임시 로그 제거와 서식 정리를 반영한 코드는 다음과 같다. 기존 판정 흐름과 Gizmo의 별도 검사는 유지했다.

using UnityEngine;

[RequireComponent(typeof(CapsuleCollider))]
public class CollisionDetector : MonoBehaviour
{
    [Header("Ground Check")]
    [SerializeField] private float groundCheckOffset = 0.1f;
    [SerializeField] private float groundCheckDistance = 0.1f;
    [SerializeField] private LayerMask _groundMask;

    private CapsuleCollider _collider;

    [SerializeField] private bool isGrounded;
    [SerializeField] private float groundAngle;
    [SerializeField] private Vector3 groundNormal;
    [SerializeField] private Vector3 groundPoint;
    [SerializeField] private float maxGroundAngle = 50f;

    public bool IsGrounded => isGrounded;
    public Vector3 GroundNormal => groundNormal;
    public Vector3 GroundPoint => groundPoint;
    public float GroundAngle => groundAngle;

    private void Awake()
    {
        _collider = GetComponent<CapsuleCollider>();
    }

    private void Update()
    {
        GroundCheck();
    }

    private void GroundCheck()
    {
        isGrounded = false;
        groundAngle = -1f;
        groundNormal = Vector3.zero;
        groundPoint = Vector3.zero;

        bool hasGroundHit;

        Vector3 localSphereCenter = _collider.center + Vector3.down * (_collider.height * 0.5f - _collider.radius);
        Vector3 worldSphereCenter = transform.TransformPoint(localSphereCenter);
        Vector3 sphereOrigin = worldSphereCenter + transform.up * groundCheckOffset;

        float sphereRadius = _collider.radius * 0.95f;
        float radiusGap = _collider.radius - sphereRadius;
        float castDistance = groundCheckOffset + radiusGap + groundCheckDistance;

        hasGroundHit = Physics.SphereCast(sphereOrigin, sphereRadius, -transform.up, out RaycastHit hit, castDistance, _groundMask, QueryTriggerInteraction.Ignore);

        if (hasGroundHit)
        {
            groundAngle = Vector3.Angle(transform.up, hit.normal);

            if (groundAngle <= maxGroundAngle)
            {
                groundNormal = hit.normal;
                groundPoint = hit.point;
                isGrounded = true;
            }
        }
    }

    private void OnDrawGizmosSelected()
    {
        CapsuleCollider col = _collider;

        if (col == null)
            col = GetComponent<CapsuleCollider>();

        if (col == null)
            return;

        float sphereRadius = col.radius * 0.95f;

        Vector3 localSphereCenter = col.center + Vector3.down * (col.height * 0.5f - col.radius);
        Vector3 worldSphereCenter = transform.TransformPoint(localSphereCenter);
        Vector3 sphereOrigin = worldSphereCenter + transform.up * groundCheckOffset;

        float radiusGap = col.radius - sphereRadius;
        float castDistance = groundCheckOffset + radiusGap + groundCheckDistance;

        Vector3 sphereEnd = sphereOrigin - transform.up * castDistance;

        bool hasHit = Physics.SphereCast(sphereOrigin, sphereRadius, -transform.up, out RaycastHit hit, castDistance, _groundMask, QueryTriggerInteraction.Ignore);

        Gizmos.color = Color.gray;
        Gizmos.DrawWireSphere(sphereOrigin, sphereRadius);
        Gizmos.DrawLine(sphereOrigin, sphereEnd);
        Gizmos.DrawWireSphere(sphereEnd, sphereRadius);

        if (!hasHit)
            return;

        float hitAngle = Vector3.Angle(transform.up, hit.normal);
        bool validGround = hitAngle <= maxGroundAngle;

        Gizmos.color = validGround ? Color.green : Color.red;
        Gizmos.DrawSphere(hit.point, 0.03f);
        Gizmos.DrawLine(hit.point, hit.point + hit.normal * 0.5f);
    }
}

현재 구현에서 남아 있는 부분

판정 결과와 이동 시스템의 연결

현재 CollisionDetector는 Query와 경사각 해석을 함께 수행하고, 자신의 Update()에서 결과를 갱신한다.

이는 최종 아키텍처를 모두 구현한 상태는 아니다. 프로젝트의 설계에서는 Collision / Physics Backend가 접촉 정보를 제공하고, Custom Movement Runtime이 그 결과를 해석하여 공통 이동 상태를 관리하도록 구분하고 있다.

따라서 이후에는 검사 시점을 이동 Tick 안에서 관리하고, 현재 공개한 결과를 실제 이동 계산과 연결하는 작업이 남아 있다. 프로퍼티가 존재하는 것과 Runtime 통합을 검증한 것은 별개의 단계다.

지면 판정만으로 낙하 관통이 해결되지는 않는다

현재 코드는 아래쪽의 짧은 범위에 유효한 지면이 있는지 확인한다. 다음 프레임까지의 이동 경로 전체를 검사하거나, 이동량을 충돌 지점까지만 제한하는 코드는 없다.

따라서 중력을 추가한 뒤에는 접지 상태뿐 아니라 실제 수직 이동량과 충돌 해결도 함께 검증해야 한다. 이전에 빠른 낙하에서 바닥을 통과했던 문제 역시, 이번의 정적 지면 판정 테스트만으로 해결되었다고 볼 수는 없다.


스케일도 남은 검토 사항이다. 현재 코드는 중심 위치에는 TransformPoint()를 사용하지만, 검사 반지름에는 _collider.radius를 그대로 사용한다. 앞에서 명시한 단위 스케일 전제를 벗어나면 위치와 크기를 함께 다시 계산해야 한다.

Git 작업

이번 작업도 feat/player-controller 브랜치에서 이어서 진행하였다.

기능을 추가하는 과정과 마지막 정리 작업은 구분하기로 했다. 마지막에 남은 diff는 Ground Detection 전체를 새로 추가한 것이 아니라, 각도 초기값 변경과 임시 로그 제거, 서식 정리였다.


특히 groundAngle = 0f에서 groundAngle = -1f로 바꾸면 외부에서 읽는 GroundAngle 값도 달라진다. 따라서 단순한 코드 구조 정리로만 보지 않고, 미검출 상태의 표현을 명확히 하는 수정으로 분류했다.

마지막 수정의 커밋 메시지는 다음과 같이 정했다.

fix: Refine Ground Detection State

프로젝트의 커밋 분류는 ‘이번에 작업한 기능 이름’보다 이전 커밋과 비교하여 실제로 무엇이 달라졌는가를 기준으로 판단해야 한다는 점도 다시 확인했다.

또한 구현 체크포인트를 커밋하는 것과 Issue #2를 최종 완료 처리하는 것은 구분했다. 실제 낙하와 이동 시스템 연결은 아직 검증하지 않았으므로, Gravity 구현 이후 접지 전환을 다시 확인하기로 했다.

이번 구현에서 느낀 점

이전 수평 이동 구현에서는 입력, 목표 속도, 현재 속도를 분리하는 것이 중요했다.

이번에는 그와 비슷하게, 검출 결과와 그 결과의 의미를 분리하는 것이 중요했다.

무언가를 맞혔다
→ 검출 결과의 법선을 읽는다
→ 기준 방향과의 각도를 계산한다
→ 허용된 경사인지 판단한다
→ 유효한 바닥일 때만 상태와 정보를 저장한다

처음에는 아래로 선 하나만 쏘면 끝나는 기능이라고 생각했지만, 실제로는 시작점의 좌표계, 구체의 반지름, offset, 검사 거리, 조건문의 실행 순서까지 연결되어 있었다.

특히 hasGroundHitisGrounded를 같은 값으로 취급하지 않게 된 것이 이번 작업의 핵심이었다. 충돌 정보가 있다는 사실과, 그 정보를 바닥으로 사용할 수 있다는 판단은 서로 다르다.

또한 디버깅에서는 결과가 이상해 보인다는 이유로 계산식을 계속 바꾸기보다, 어느 지점에서 어떤 값을 얻었는지 확인하는 편이 원인을 좁히는 데 도움이 되었다.

결론

이번 작업으로 캐릭터는 주변의 모든 물리 상황을 처리하게 된 것은 아니다. 아직 스스로 떨어지지도 않고, 경사면을 따라 걷지도 않는다.

대신 이제는 발 아래에 검사 대상이 있는지, 그 표면을 유효한 지면으로 인정할 수 있는지, 검출된 지점과 방향이 무엇인지를 구분할 수 있게 되었다.

이는 이후 Gravity와 Jump, Slope 처리에서 사용할 기초 정보가 된다.


다음 작업에서는 이 판정 결과를 실제 이동 코드와 연결하여 중력을 구현하고, 낙하와 착지 과정에서도 접지 상태가 의도대로 전환되는지 확인할 예정이다.


0.1.0 - 2 버전 테스트 영상

RELATED RECORDS

同じプロジェクト、または近いテーマの記録です。