투영: 벽에 막힌 이동을 벽을 따라 미끄러지게 바꾸기
Esc

    투영: 벽에 막힌 이동을 벽을 따라 미끄러지게 바꾸기

    –Unity기초

    캐릭터를 코드로 직접 움직이면, 벽에 비스듬히 달려갈 때 캐릭터가 벽에 딱 붙어 멈추는 일이 있습니다. 흔히 쓰는 충돌 처리가 “이번 이동이 벽 안으로 들어가는가” 만 보고 이동 전체를 막기 때문입니다. (2, 1) 만큼 가려 했다면 벽으로 들어가는 몫은 오른쪽 2 뿐인데, 벽을 따라가는 위쪽 1 까지 같이 버려집니다.

    이동 벡터를 두 조각으로 나누면 풀립니다. 벽으로 파고드는 조각과 벽을 따라가는 조각입니다. 파고드는 조각만 빼면 미끄러지고, 한 번 더 빼면 튕겨 나옵니다. 나누는 기준은 벽이 바라보는 방향인 법선이고, 계산은 내적입니다.

    법선 n 이 길이 1 일 때, 이동 v 에서 벽으로 파고드는 조각은 Dot(v, n) × n 입니다. 이것을 빼면 벽을 따라가는 조각만 남습니다(Vector3.ProjectOnPlane). 두 번 빼면 반사입니다(Vector3.Reflect). 경사면을 따라 오르게 하는 것도 같은 계산입니다.

    벡터를 법선 방향과 나머지로 나눈다

    위에서 내려다본 바닥으로 생각합니다. 숫자를 줄이려고 바닥의 가로(x)와 세로(z) 두 숫자로만 적습니다. x = 3 자리에 벽이 세로로 서 있고, 캐릭터는 벽의 왼쪽에 있습니다. 법선은 바닥에 선 기둥처럼 면과 직각(90°), 곧 수직으로 서서 캐릭터가 있는 바깥쪽을 향하는 길이 1 인 방향입니다. 이 벽에서 캐릭터 쪽을 보는 법선은 왼쪽을 향하는 n = (−1, 0) 입니다. 캐릭터는 이번 프레임에 v = (2, 1) 만큼, 곧 오른쪽으로 2, 위로 1 가려고 합니다.

    내적은 첫째 숫자끼리, 둘째 숫자끼리 곱해서 더한 숫자입니다(3D 면 x · y · z 세 칸 모두). n 이 길이 1 이면 Dot(v, n) 은 v 를 n 위에 내린 그림자의 길이가 됩니다. 내적은 v 의 길이 × n 의 길이 × cos(사이각) 과 같고, cos 는 직각삼각형에서 밑변 ÷ 빗변 이라 빗변 × cos = 밑변 이기 때문입니다. n 의 길이가 1 이니 v 의 길이 × cos, 곧 그림자 길이만 남습니다. 그림자가 n 과 반대쪽에 생기면 음수입니다.

    그림자의 길이에 n 을 곱하면 n 방향으로 간 조각이 나옵니다. 빛을 수직으로 비춰 그림자를 내리듯 벡터를 한 방향 위로 눕혀 그 몫만 떼어 내는 이 계산을 투영(projection)이라고 부릅니다. v 에서 이 조각을 빼면 n 과 수직인 조각, 곧 벽면을 따라가는 조각이 남습니다.

    단계 계산 결과
    1. 그림자 길이 Dot(v, n) = 2 × (−1) + 1 × 0 −2
    2. 파고드는 조각 −2 × n = −2 × (−1, 0) (2, 0)
    3. 벽을 따라가는 조각 v − (2, 0) = (2, 1) − (2, 0) (0, 1)
    4. 튕겨 나오는 방향 v − 2 × (2, 0) = (2, 1) − (4, 0) (−2, 1)

    1 단계가 음수인 것은 v 가 법선과 반대쪽, 곧 벽 안쪽으로 가고 있다는 뜻입니다. 3 단계의 (0, 1) 이 정말 벽을 따라가는지는 법선과의 내적이 0 인지로 확인합니다. 0 × (−1) + 1 × 0 = 0, 수직입니다.

    파고드는 조각을 한 번 빼면 미끄러지고, 두 번 빼면 튕깁니다. 벽을 따라가는 조각은 두 경우 모두 그대로입니다. 튕긴 (−2, 1) 은 처음 (2, 1) 과 길이가 같습니다(둘 다 √5). 반사는 방향만 바꾸고 빠르기는 그대로 둡니다.

    Project · ProjectOnPlane · Reflect

    유니티에는 세 가지가 함수로 있습니다. 벽이나 바닥처럼 평평하게 펼쳐진 면을 수학에서는 평면(Plane)이라고 부르고, ProjectOnPlane 은 평면 위로 투영한다는 뜻입니다.

    함수 계산 위 예의 결과
    Vector3.Project(v, n) Dot(v, n) × n n 방향 조각 (2, 0)
    Vector3.ProjectOnPlane(v, n) v − Project(v, n) 면을 따라가는 조각 (0, 1)
    Vector3.Reflect(v, n) v − 2 × Dot(v, n) × n 반사된 방향 (−2, 1)

    법선이 길이 1 이어야 하는 이유도 숫자로 보입니다. 같은 벽의 법선을 길이 2 인 (−2, 0) 으로 넣으면 Dot(v, n) = −4, 여기에 n 을 곱하면 (8, 0) 으로 진짜 조각 (2, 0) 의 네 배가 됩니다. Dot(v, n) 을 구할 때 n 의 길이 2 가 한 번, 그 결과에 n 을 곱할 때 또 한 번 곱해졌기 때문이라, 2 × 2 = 4 로 나눠야 (2, 0) 이 됩니다.

    Project 와 ProjectOnPlane 은 안에서 이 나눗셈을 해 주므로 n 이 길이 1 이 아니어도 됩니다.

    Reflect 는 나누지 않고 위 식을 그대로 계산하므로, 직접 만든 법선이면 .normalized 를 붙여 넣습니다. .normalized 는 방향은 그대로 두고 길이만 1 로 맞춘 벡터(정규화)입니다. (3, 4) 는 길이 5 라서 5 로 나누면 (0.6, 0.8) 입니다. 레이캐스트(한 점에서 한 방향으로 보이지 않는 선을 쏴서, 처음 닿은 물체와 그 지점의 법선을 알려 주는 기능)가 돌려주는 RaycastHit.normal 은 이미 길이 1 입니다.

    벽에서 멀어질 때는 빼지 않는다

    미끄러지기를 코드로 옮길 때 자주 빠뜨리는 것이 있습니다. 벽으로 가고 있을 때만 빼야 합니다. 벽에 붙은 채로 벽에서 멀어지는 쪽, 예를 들어 (−2, 1) 로 가려고 하면 Dot(v, n) = (−2) × (−1) + 1 × 0 = 2 로 양수입니다. 이때 무조건 빼면 벽에 달라붙고, 음수일 때만 빼면 그대로 떨어져 나갑니다.

    // hit 은 이동 방향으로 쏜 레이캐스트의 결과. normal 은 벽이 바라보는 방향
    float into = Vector3.Dot(move, hit.normal);
    
    // 0 보다 작을 때만 벽으로 파고드는 중이다
    if (into < 0f)
    {
        move = Vector3.ProjectOnPlane(move, hit.normal);
    }
    Dot(v, n) 뜻 할 일
    음수 벽으로 들어감 파고드는 조각을 뺀다
    0 벽과 나란히 감 그대로
    양수 벽에서 멀어짐 그대로 (빼면 벽에 달라붙음)

    경사면을 따라 오르기

    경사면에서도 같은 계산을 합니다. 이번에는 옆에서 본 그림이라 가로가 앞(z), 세로가 위(y) 입니다. 오르막 경사면의 법선을 n = (−0.6, 0.8) 이라고 합니다. 0.6² + 0.8² = 0.36 + 0.64 = 1 이라 길이 1 입니다. 앞으로 (5, 0) 만큼 가려고 하면, 수평으로 곧장 가서 경사면 속으로 파고듭니다.

    단계 계산 결과
    1. 그림자 길이 Dot((5, 0), n) = 5 × (−0.6) + 0 × 0.8 −3
    2. 파고드는 조각 −3 × (−0.6, 0.8) (1.8, −2.4)
    3. 경사면을 따라가는 조각 (5, 0) − (1.8, −2.4) (3.2, 2.4)
    4. 그 길이 √(3.2² + 2.4²) = √(10.24 + 5.76) 4

    투영하면 길이가 줄어듭니다. 파고드는 조각을 잘라 냈으니 당연합니다. 경사가 가파를수록 많이 줄고, 직각인 벽이면 0 이 됩니다. 비탈에서도 평지와 같은 빠르기로 걷게 하려면, 투영한 뒤 길이를 1 로 맞추고(normalized) 빠르기를 다시 곱합니다.

    // 발밑 경사면의 법선. groundHit 은 아래로 쏜 레이캐스트의 결과
    Vector3 slopeNormal = groundHit.normal;
    
    Vector3 alongSlope = Vector3.ProjectOnPlane(moveDirection, slopeNormal);
    
    // 비탈이어도 같은 빠르기
    Vector3 velocity = alongSlope.normalized * speed;

    다만 벽에서는 이렇게 길이를 되살리면 안 됩니다. 벽에 거의 정면으로 부딪혀 따라가는 조각이 아주 작은데, 그것을 길이 1 로 키우면 벽에 닿기만 해도 옆으로 전속력으로 미끄러집니다. 벽은 줄어든 길이 그대로, 경사면은 길이를 되살려서 쓰는 것이 보통입니다.

    정리

    • 법선은 면에 수직이고 바깥을 향하는 길이 1 인 방향입니다
    • 이동 v 는 법선 방향 조각 Dot(v, n) × n 과, 면을 따라가는 나머지로 나뉩니다. n 이 길이 1 이면 내적이 곧 그림자 길이입니다
    • 법선 방향 조각을 한 번 빼면 미끄러짐(ProjectOnPlane), 두 번 빼면 반사(Reflect)입니다. 반사는 길이를 지킵니다
    • Dot(v, n) 이 음수일 때만 면으로 파고드는 중입니다. 양수일 때 빼면 벽에 달라붙습니다
    • Project 와 ProjectOnPlane 은 법선 길이로 나눠 주고, Reflect 는 법선이 길이 1 이라고 보고 계산합니다
    • 투영하면 길이가 줄어듭니다. 경사면은 다시 정규화해서 빠르기를 맞추고, 벽은 줄어든 대로 씁니다