투영: 벽에 막힌 이동을 벽을 따라 미끄러지게 바꾸기
캐릭터를 코드로 직접 움직이면, 벽에 비스듬히 달려갈 때 캐릭터가 벽에 딱 붙어 멈추는 일이 있습니다. 흔히 쓰는 충돌 처리가 “이번 이동이 벽 안으로 들어가는가” 만 보고 이동 전체를 막기 때문입니다. (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 이라고 보고 계산합니다- 투영하면 길이가 줄어듭니다. 경사면은 다시 정규화해서 빠르기를 맞추고, 벽은 줄어든 대로 씁니다