대각선으로 가면 빨라지는 이유
키보드로 캐릭터를 움직여 보면, W 만 누를 때보다 W 와 D 를 같이 누를 때 캐릭터가 눈에 띄게 빨리 갑니다. 대각선으로 달리는 쪽이 41% 빠릅니다. 대각선 이동만 쓰는 사람이 이기는 게임이 됩니다.
원인은 입력 벡터의 길이입니다. 벡터는 방향과 길이를 가진 화살표이고, (3, 4) 는 “오른쪽으로 3, 위로 4” 입니다. 앞만 누르면 입력이 (0, 1) 이고 길이가 1 입니다. 앞과 오른쪽을 같이 누르면 (1, 1) 인데, 이 화살표의 길이는 1 이 아니라 √2 ≈ 1.41 입니다. √2 는 두 번 곱해서 2 가 되는 수입니다. 같은 빠르기를 곱하면 대각선만 1.41배로 갑니다.
벡터의 길이는 피타고라스로 √(x² + y²) 입니다. 길이로 각 자리를 나누면 방향은 그대로이고 길이만 1 인 벡터가 됩니다. 이것을 정규화라고 부릅니다. 이동 방향은 정규화한 뒤에 빠르기를 곱해야 어느 쪽으로 가든 빠르기가 같습니다.
벡터의 길이는 피타고라스로 잰다
(3, 4) 를 그리면 가로로 3, 세로로 4 를 간 화살표입니다. 화살표 끝까지 곧장 잰 길이가 벡터의 길이입니다. 가로와 세로는 직각(90°)으로 만나니, 화살표는 직각삼각형(한 각이 90° 인 삼각형)의 빗변이 됩니다. 빗변은 직각 맞은편의 비스듬한 변입니다.
빗변의 길이는 피타고라스의 정리로 구합니다. 각 변 위에 정사각형을 세우면, 빗변 위 정사각형의 넓이가 나머지 두 정사각형 넓이의 합과 같다는 정리입니다. 정사각형의 넓이는 한 변 × 한 변 입니다. 같은 수를 두 번 곱하는 것을 제곱이라 하고 3² = 3 × 3 = 9 처럼 씁니다. 그래서 식으로는 빗변² = 가로² + 세로² 입니다. 빗변² 에서 빗변으로 돌아갈 때는 제곱근(√, 루트)을 씁니다. 두 번 곱해서 그 수가 되는 수이고, 5 × 5 = 25 라서 √25 = 5 입니다.
3차원이면 z 하나가 더 붙을 뿐입니다. (x, y, z) 의 길이는 √(x² + y² + z²) 이고, (1, 2, 2) 는 √(1 + 4 + 4) = √9 = 3 입니다.
| 유니티 | 값 | (3, 4) 일 때 |
|---|---|---|
v.magnitude |
√(x² + y²) | 5 |
v.sqrMagnitude |
x² + y² (루트를 안 씌운 값) | 25 |
대각선 입력은 길이가 1 이 아니다
키보드 입력을 방향 벡터로 바꾸면 이렇게 됩니다.
| 누른 키 | 입력 | 길이 | 빠르기 5 를 곱하면 1초에 |
|---|---|---|---|
| W | (0, 1) | √(0 + 1) = 1 | 5 |
| D | (1, 0) | √(1 + 0) = 1 | 5 |
| W + D | (1, 1) | √(1 + 1) = √2 ≈ 1.41 | 7.07 |
여덟 방향 입력을 원점에서 그리면 대각선 넷만 길이 1 인 점선 원 밖으로 나갑니다.
빠르기는 방향 벡터의 길이 × 빠르기 로 정해집니다. 방향 벡터의 길이가 방향마다 다르면 빠르기도 방향마다 달라집니다.
그래서 방향을 나타내는 벡터는 길이를 1 로 맞춰 두고 쓰는 것이 약속입니다. 길이가 1 인 벡터를 단위 벡터라고 부릅니다.
길이가 1 이면 빠르기 5 를 곱한 결과의 길이가 정확히 5 라서, 곱한 숫자가 곧 빠르기가 됩니다.
transform.forward, Vector3.right 같은 값은 처음부터 단위 벡터입니다.
정규화: 길이로 나눠서 1 로 만든다
길이를 1 로 만들려면 각 자리를 그 벡터의 길이로 나누면 됩니다. x 와 y 를 같은 수로 나누니 비율이 그대로라서 방향은 바뀌지 않습니다.
(3, 4) 는 길이가 5 입니다.
| 단계 | 계산 | 결과 |
|---|---|---|
| 1. 길이 재기 | √(3² + 4²) | 5 |
| 2. 길이로 나누기 | (3 ÷ 5, 4 ÷ 5) | (0.6, 0.8) |
| 3. 길이 확인 | √(0.6² + 0.8²) = √(0.36 + 0.64) | 1 |
| 4. 빠르기 2 곱하기 | (0.6 × 2, 0.8 × 2) | (1.2, 1.6), 길이 2 |
이동 코드에 넣으면 이렇습니다.
// GetAxisRaw 는 키를 누르면 -1, 0, 1 중 하나를 그대로 돌려준다
// 그래서 W 와 D 를 같이 누르면 (1, 0, 1) 이 된다
Vector3 input = new Vector3(
Input.GetAxisRaw("Horizontal"),
0f,
Input.GetAxisRaw("Vertical"));
// 대각선이어도 길이 1
Vector3 direction = input.normalized;
// Time.deltaTime 은 지난 프레임이 걸린 초. 곱하면 1초에 speed 만큼 간다
transform.position += direction * speed * Time.deltaTime;
유니티에는 정규화가 두 가지 있습니다.
| 코드 | 하는 일 |
|---|---|
v.normalized |
길이 1 인 새 벡터를 돌려줍니다. v 는 그대로입니다 |
v.Normalize() |
v 자기 자신을 길이 1 로 바꿉니다 |
스틱 입력은 정규화하면 안 된다
게임패드 스틱은 조금 기울이면 입력이 작게 들어옵니다. 살짝 기울여 (0.3, 0.4) 가 들어왔다면 길이는 0.5 입니다. “천천히 걷고 싶다” 는 뜻인데, 이것을 정규화하면 (0.6, 0.8) 로 길이가 1 이 되어 늘 전속력이 됩니다.
이때는 길이가 1 을 넘을 때만 1 로 줄이는 Vector3.ClampMagnitude 를 씁니다.
| 입력 | 길이 | normalized |
ClampMagnitude(v, 1) |
|---|---|---|---|
| (0.3, 0.4) 살짝 | 0.5 | (0.6, 0.8), 길이 1 | 그대로, 길이 0.5 |
| (1, 1) 키보드 대각선 | 1.41 | (0.71, 0.71), 길이 1 | (0.71, 0.71), 길이 1 |
| (0, 1) 앞 | 1 | 그대로 | 그대로 |
정리하면 방향만 필요하면 normalized, 세기도 살려야 하면 ClampMagnitude 입니다.
적을 향해 쏘는 총알처럼 길이가 거리라서 의미 없는 경우는 정규화하고, 스틱처럼 길이가 세기인 경우는 길이를 남깁니다.
정규화로 얻는 것과 치르는 것을 나란히 두면 이렇습니다.
| 내용 | |
|---|---|
| 얻는 것 | 어느 방향이든 길이가 1 이라 빠르기가 방향마다 같아집니다 |
| 잃는 것 | 원래 길이. 스틱의 세기, 적까지의 거리가 사라집니다 |
| 비용 | 루트 한 번과 자리(x, y, z) 수만큼의 나눗셈. 매 프레임 수천 번이 아니면 신경 쓸 정도는 아닙니다 |
| 안 되는 경우 | 길이가 0 인 벡터 |
영벡터는 정규화할 수 없다
아무 키도 안 누르면 입력은 (0, 0) 입니다. 길이가 0 이라서 0 으로 나눠야 하는데, 0 으로는 나눌 수 없습니다. (0, 0) 에는 처음부터 방향이 없으니 “길이 1 인 같은 방향” 도 없습니다.
유니티는 이때 오류를 내지 않고 (0, 0, 0) 을 그대로 돌려줍니다. 길이가 아주 작은 벡터도 마찬가지입니다. 움직일 때는 문제가 없지만, 이동 방향으로 캐릭터를 돌리는 코드는 입력이 없을 때 이상한 방향으로 돌 수 있습니다. 방향이 필요한 코드는 길이가 있는지 먼저 확인합니다.
// 입력이 거의 없으면 방향을 바꾸지 않는다
if (input.sqrMagnitude > 0.0001f)
{
transform.forward = input.normalized;
}
거리를 비교할 때는 sqrMagnitude
공격 범위 안에 적이 있는지 볼 때는 거리를 재서 범위와 비교합니다. 이때 실제 거리 값이 필요한 게 아니라 어느 쪽이 큰지만 알면 됩니다.
거리가 둘 다 0 보다 크면, 제곱해도 크고 작은 순서가 바뀌지 않습니다. 거리 4 와 5 를 비교하는 것과 16 과 25 를 비교하는 것은 답이 같습니다.
그래서 루트를 씌우지 않은 sqrMagnitude 를 범위의 제곱과 비교하면 루트 계산을 건너뛸 수 있습니다.
루트는 곱하기나 더하기보다 오래 걸리는 계산이라, 적 수백 마리와의 거리를 매 프레임 재는 코드라면 이 차이가 쌓입니다. 유니티 문서도 거리 비교에는 sqrMagnitude 를 쓰라고 권합니다.
float range = 5f;
Vector3 toEnemy = enemy.position - transform.position;
// toEnemy.magnitude < range 와 같은 판정
if (toEnemy.sqrMagnitude < range * range)
{
Attack();
}
적이 (3, 4) 만큼 떨어져 있으면 sqrMagnitude 는 25, 범위 5 의 제곱도 25 라서 딱 경계입니다.
범위 쪽도 제곱해야 한다는 것을 빠뜨리기 쉽습니다. sqrMagnitude < 5 로 쓰면 실제로는 거리 √5 ≈ 2.2 안쪽만 잡힙니다.
정리
- 벡터의 길이는 피타고라스로 √(x² + y²) 입니다. 3차원이면 z² 가 더 붙습니다
- 키보드 대각선 입력 (1, 1) 의 길이는 √2 ≈ 1.41 이라 같은 빠르기를 곱하면 41% 빨라집니다
- 각 자리를 길이로 나누는 것이 정규화입니다. 방향은 그대로, 길이는 1 이 됩니다
- 방향 벡터는 길이 1 로 맞춰 두고 빠르기를 곱합니다. 그래야 빠르기가 곧 길이가 됩니다
- 스틱처럼 길이가 세기인 입력은 정규화하지 말고
ClampMagnitude(v, 1)로 1 만 넘지 않게 합니다 - 영벡터는 정규화할 수 없습니다. 유니티는 (0, 0, 0) 을 돌려주니 방향이 필요한 코드는 길이부터 확인합니다
- 거리 비교는
sqrMagnitude < 범위 * 범위로 루트를 건너뜁니다