점프가 포물선이 되는 이유
CharacterController 로 점프를 만들려고 점프 키를 누르면 y 를 2 만큼 올리고, 매 프레임 조금씩 내렸더니 캐릭터가 순간이동하듯 올라갔다가 엘리베이터처럼 일정하게 내려옵니다. 진짜 점프는 빠르게 솟았다가 꼭대기에서 잠깐 멈칫하고, 점점 빨라지며 떨어집니다.
중력이 위치가 아니라 속도를 바꾸기 때문입니다. 점프 키는 위로 향한 속도를 한 번 주고, 중력은 매 프레임 그 속도를 조금씩 아래로 바꿉니다. 속도가 일정하게 줄어드니, 위치는 처음엔 많이 오르다가 점점 덜 오르고, 꼭대기를 지나면 점점 더 많이 떨어집니다. 그렇게 그려지는 곡선이 포물선입니다.
매 프레임
속도 += 중력 × 시간,위치 += 속도 × 시간두 줄이면 포물선이 나옵니다. 이 계산은 한 번에 맡는 시간(계산 간격)이 크면 오차가 커져서, fps 가 다르면 점프 높이도 조금씩 달라집니다. 원하는 높이 h 로 뛰게 하려면 처음 속도를 √(2 × 중력 × h) 로 줍니다.
중력은 속도를 바꾸는 가속도다
속도는 위치가 1초에 얼마나 바뀌나이고, 단위는 m/s(미터 매 초)입니다. 속도가 위로 8 이면 1초에 8m 오르고, 0.5초면 4m 오릅니다. 속도가 1초에 얼마나 바뀌는지는 가속도라 부르고, 단위는 m/s²(1초마다 m/s 가 얼마)입니다. 가속도가 아래로 10 이면 속도가 1초마다 10 씩 아래로 바뀝니다. 위로 8 이던 속도는 0.5초 뒤 3, 0.8초 뒤 0, 1초 뒤 −2(아래로 2) 가 됩니다.
중력이 바로 이 가속도입니다. 지구에서는 아래로 약 9.81 이고, 유니티의 기본 중력 Physics.gravity 도 (0, −9.81, 0) 입니다. 아래 계산은 암산하기 쉽게 −10 으로 둡니다.
속도와 가속도는 둘 다 방향이 있으니 벡터(방향과 길이를 가진 화살표, 유니티에서는 Vector3)로 씁니다.
게임은 시간을 프레임(화면에 그리는 그림 한 장) 단위로 끊어서 계산합니다. 계산 한 번이 맡는 시간을 dt(델타 타임)라고 부르고, Update 안이면 지난 프레임이 걸린 초인 Time.deltaTime 이 그것입니다. dt 초가 지날 때마다 이렇게 바꿉니다.
속도 = 속도 + 가속도 × dt
위치 = 위치 + 속도 × dt
0.2초씩 계산하면
처음 속도 (2, 8) 로 뛰었다고 합니다. 가로로 1초에 2, 위로 1초에 8 입니다. 중력은 (0, −10), 계산 간격 dt 는 눈으로 따라가기 쉽게 0.2초로 크게 잡습니다. 가로 속도는 아무것도 바꾸지 않으니 계속 2 이고, 세로 속도만 0.2초마다 10 × 0.2 = 2 씩 줄어듭니다. 속도를 먼저 바꾸고, 바뀐 속도로 위치를 옮깁니다.
| 시각 | 속도 y | 이번에 오른 양 (속도 y × 0.2) | 높이 |
|---|---|---|---|
| 0 | 8 | 0 | |
| 0.2 | 6 | 1.2 | 1.2 |
| 0.4 | 4 | 0.8 | 2.0 |
| 0.6 | 2 | 0.4 | 2.4 |
| 0.8 | 0 | 0 | 2.4 |
| 1.0 | −2 | −0.4 | 2.0 |
| 1.2 | −4 | −0.8 | 1.2 |
| 1.4 | −6 | −1.2 | 0 |
오르는 양이 1.2, 0.8, 0.4, 0 으로 일정하게 줄고, 내려오는 양이 0.4, 0.8, 1.2 로 일정하게 늘어납니다. 속도가 일정하게 바뀌면 위치는 이렇게 휘어집니다.
포물선과 t²: 잘게 쪼개면 나오는 정확한 식
포물선은 공을 비스듬히 던졌을 때 그리는 곡선입니다. 꼭대기를 가운데에 두고 왼쪽과 오른쪽이 같은 모양으로 휘어 있습니다. 계산 간격을 아주 잘게 쪼개면 높이가 어떤 식이 되는지, 평균 속도로 구할 수 있습니다.
속도가 일정하게 바뀔 때는 그동안의 평균 속도가 처음 속도와 나중 속도의 한가운데입니다. 8 에서 0 으로 고르게 줄었다면 평균은 4 입니다. 처음 0.8초 동안 속도는 8 에서 0 으로 줄었으니 평균 4 로 0.8초를 간 셈이고, 오른 높이는 4 × 0.8 = 3.2 입니다. 이것이 정확한 꼭대기입니다.
아무 시각 t 초에 대해서도 같은 방법이 됩니다.
| 단계 | 계산 |
|---|---|
| t 초 뒤 속도 | 8 − 10t |
| 그동안의 평균 속도 | (8 + 8 − 10t) ÷ 2 = 8 − 5t |
| t 초 동안 오른 높이 = 평균 속도 × t | (8 − 5t) × t = 8t − 5t² |
t² 은 t × t 입니다(같은 수를 두 번 곱한 것, “t 의 제곱”). 이 식이 물리 교과서의 높이 = 처음 속도 × t − ½ × 중력 × t² 입니다. 5 가 ½ × 10 입니다.
| t | 8t | 5t² | 높이 |
|---|---|---|---|
| 0.2 | 1.6 | 0.2 | 1.4 |
| 0.4 | 3.2 | 0.8 | 2.4 |
| 0.8 | 6.4 | 3.2 | 3.2 (꼭대기) |
| 1.6 | 12.8 | 12.8 | 0 (바닥) |
8t 는 시간에 비례해 곧게 늘어나지만 5t² 은 시간이 2배면 4배로 늘어납니다. 처음엔 8t 가 이겨서 오르다가, 나중엔 5t² 이 따라잡아 끌어내립니다. 이렇게 t² 이 들어간 식의 그래프가 포물선입니다.
계산이 정확한 값과 어긋나는 이유
0.2초씩 계산한 꼭대기는 2.4 였고, 정확한 꼭대기는 3.2 입니다. 0.8 이 모자랍니다. 원인은 0.2초 동안 속도가 바뀌는데, 계산은 그 0.2초 내내 한 가지 속도만 쓴다는 데 있습니다. 첫 0.2초 동안 실제 속도는 8 에서 6 으로 서서히 줄어 평균 7 이지만, 계산은 줄어든 뒤의 6 으로 0.2초를 다 간 것으로 칩니다. 그래서 매번 조금씩 덜 오릅니다. 간격을 줄이면 오차도 줄어듭니다.
| 계산 간격 | 꼭대기 높이 | 정확한 값과의 차이 |
|---|---|---|
| 0.2초 | 2.4 | −0.8 |
| 0.05초 | 3.0 | −0.2 |
| 아주 잘게 | 3.2 | 0 |
이렇게 짧은 간격마다 “바뀌는 양 × 간격” 을 더해 모아 가는 계산을 수치 적분이라고 부릅니다. 적분은 “잘게 나눠 더해 모은다” 는 뜻입니다. 위의 가장 단순한 방식은 수학자 오일러의 이름을 따 오일러 방법이라고 합니다.
두 줄의 순서도 결과를 바꿉니다. 위치를 먼저 옮기면 줄기 전의 속도를 쓰게 됩니다.
| 순서 | 이름 | 첫 0.2초에 쓰는 속도 | 0.2초 간격 꼭대기 |
|---|---|---|---|
| 속도 먼저, 새 속도로 위치 | 반암시적(semi-implicit) 오일러 | 6 | 2.4 (0.8 낮음) |
| 위치 먼저, 옛 속도로 위치 | 명시적(explicit) 오일러 | 8 | 4.0 (0.8 높음) |
점프에서는 둘 다 0.8 씩 어긋나지만, 용수철처럼 되돌아오는 힘이 있으면 차이가 커집니다. 명시적 오일러는 흔들림이 갈수록 커지며 에너지가 불어나 터지고, 반암시적 오일러는 오래 돌려도 흔들림의 크기를 대체로 지킵니다. 그래서 게임 물리는 보통 속도를 먼저 바꾸고 그 속도로 위치를 옮기는 순서를 씁니다. Glenn Fiedler 의 「Integration Basics」 도 대부분의 상용 게임 물리 엔진이 이 방식을 쓴다고 정리합니다.
이 오차는 게임에서 그대로 드러납니다. 아래 코드로 2m 점프를 만들면 꼭대기가 144fps 에서 약 1.98m, 60fps 에서 1.95m, 30fps 에서 1.90m 입니다. 프레임 시간이 긴 기계일수록 간격이 큰 계산이 되어 점프가 낮아집니다. 유니티의 물리 엔진이 화면 프레임과 따로 늘 같은 간격(기본 0.02초)으로 도는 이유 중 하나가 이것입니다. 간격이 같으면 기계가 달라도 같은 높이로 뜁니다.
CharacterController 로 점프하기
CharacterController 는 벽과 바닥에 막히며 움직이는 기능만 있고 중력은 없어서, 위의 두 줄을 직접 씁니다.
public float gravity = -9.81f;
public float jumpHeight = 2f;
CharacterController controller;
Vector3 velocity; // 속도는 프레임을 넘어 쌓여야 하니 필드로 둔다
void Start()
{
controller = GetComponent<CharacterController>();
}
void Update()
{
if (controller.isGrounded && velocity.y < 0f)
{
velocity.y = -1f; // 땅에 있으면 떨어지는 속도가 끝없이 쌓이지 않게 작게 눌러 둔다
}
if (controller.isGrounded && Input.GetButtonDown("Jump"))
{
// 원하는 높이에 닿는 처음 속도. 다음 절의 식
velocity.y = Mathf.Sqrt(2f * -gravity * jumpHeight);
}
velocity.y += gravity * Time.deltaTime; // 속도 += 가속도 × dt
controller.Move(velocity * Time.deltaTime); // 위치 += 속도 × dt
}
점프 키는 속도를 한 번 바꿀 뿐이고, 그 뒤의 오르내림은 전부 중력이 속도를 바꾼 결과입니다.
Rigidbody 를 쓰면 물리 엔진이 이 두 줄을 0.02초 간격으로 대신 계산합니다. 그때도 점프는 위치가 아니라 속도를 넣는 것이라, 위로 향한 속도를 같은 식으로 줍니다.
원하는 높이로 뛰게 하는 처음 속도
“2m 높이로 뛰게 하고 싶다” 가 보통의 요구입니다. 처음 속도를 몇으로 줘야 할까요. 꼭대기에서 속도가 0 이 된다는 것과 평균 속도를 씁니다.
| 단계 | 식 | v = 8, g = 10 일 때 |
|---|---|---|
| 위로 v 로 출발해 1초에 g 씩 줄면, 0 이 될 때까지 | 시간 = v ÷ g | 8 ÷ 10 = 0.8초 |
| 그동안 평균 속도 (v 와 0 의 한가운데) | v ÷ 2 | 4 |
| 오른 높이 = 평균 속도 × 시간 | (v ÷ 2) × (v ÷ g) = v² ÷ (2g) | 4 × 0.8 = 3.2 |
높이 h = v² ÷ (2g) 를 거꾸로 풀면 v² = 2 × g × h 입니다. v 는 “두 번 곱해서 2gh 가 되는 수” 이고, 이것을 제곱근이라 부르며 √ 로 씁니다. √64 = 8 입니다(8 × 8 = 64). 코드에서는 Mathf.Sqrt 입니다.
처음 속도 v = √(2 × g × 높이)
| 원하는 높이 | 중력 10 일 때 처음 속도 | 확인: v² ÷ 20 |
|---|---|---|
| 3.2 | √64 = 8 | 64 ÷ 20 = 3.2 |
| 2 | √40 ≈ 6.32 | 40 ÷ 20 = 2 |
| 0.8 | √16 = 4 | 16 ÷ 20 = 0.8 |
이 식으로 처음 속도를 정하면 중력을 바꿔도 점프 높이는 그대로입니다. 중력을 키우면 같은 높이를 더 빨리 오르내려 묵직해지고, 줄이면 둥실 뜹니다. 높이와 느낌을 따로 조절할 수 있습니다.
정리
- 속도는 위치가 1초에 바뀌는 양, 가속도는 속도가 1초에 바뀌는 양입니다. 중력은 아래로 향한 가속도(유니티 기본 9.81)입니다
- 중력은 위치가 아니라 속도를 바꿉니다. 매 프레임
속도 += 중력 × dt,위치 += 속도 × dt입니다 - 속도가 일정하게 바뀌면 높이는
처음 속도 × t − ½ × 중력 × t²이 되어 포물선을 그립니다. 평균 속도 × 시간으로 구할 수 있습니다 - 계산 간격 동안 속도를 하나로 고정해서 오차가 생깁니다. 간격이 크면 꼭대기가 낮아지고, 그래서 fps 가 다르면 점프 높이도 다릅니다
- 속도를 먼저 바꾸고 그 속도로 위치를 옮기는 반암시적 오일러가 게임 물리의 기본 순서입니다
- 원하는 높이 h 로 뛰려면 처음 속도를 √(2 × g × h) 로 줍니다