매 프레임 Lerp(a, b, 0.1f) 를 쓰면 생기는 일
카메라가 플레이어를 부드럽게 따라가게 하려고 매 프레임 Vector3.Lerp(카메라 위치, 플레이어 위치, 0.1f) 를 넣는 코드는 아주 흔합니다.
그런데 144fps PC 에서는 카메라가 딱 붙어 따라오고, 30fps 휴대폰에서는 늘어지게 따라옵니다.
이 코드는 매 프레임 남은 거리의 10% 를 가고, 1초에 몇 번 하느냐는 fps 가 정하기 때문입니다. “0.1 대신 속도 × deltaTime” 도 딱 맞지 않습니다. 남은 거리는 곱해져서 줄어드는데 그 고침은 더하기에 맞춘 것이라서입니다.
x = Lerp(x, 목표, 0.1f)는 남은 거리를 프레임마다 0.9 배로 만듭니다. 1초에 0.9 를 몇 번 곱하느냐가 fps 라서 빠르기가 달라집니다. “1초에 남는 비율” 을 정하고, 한 프레임에는 그 비율을 deltaTime 초 치만큼만 적용하면 맞습니다. 코드로는 비율을1 − Mathf.Exp(−k × Time.deltaTime)로 줍니다.
Lerp 는 두 점 사이의 비율 지점이다
Lerp 는 Linear interpolation 의 줄임말입니다. 보간(interpolation)은 두 값 사이의 중간값을 구하는 것이고, linear 는 곧은 선이라는 뜻입니다.
Mathf.Lerp(a, b, t) 는 a 에서 b 로 가는 길의 t 비율 지점입니다. 비율은 전체의 몇 배인가로, 0.1 은 10%, 0.25 는 4분의 1, 1 은 전부입니다. 그래서 t 가 0 이면 a, 1 이면 b, 0.5 면 한가운데입니다.
a 에서 b 까지 거리(b − a)에 t 를 곱한 만큼 a 에서 간 자리라서 식은 이렇습니다.
Lerp(a, b, t) = a + (b − a) × t
| 식 | 계산 | 결과 |
|---|---|---|
Lerp(0, 10, 0) |
0 + 10 × 0 | 0 |
Lerp(0, 10, 0.25) |
0 + 10 × 0.25 | 2.5 |
Lerp(0, 10, 1) |
0 + 10 × 1 | 10 |
Lerp(4, 10, 0.5) |
4 + 6 × 0.5 | 7 |
Vector3.Lerp 는 x, y, z 에 따로 같은 계산을 합니다. 그래서 결과는 a 와 b 를 잇는 곧은 선 위의 점입니다.
이렇게 a 와 b 를 고정해 두고 t 만 0 에서 1 로 늘리는 것이 Lerp 의 원래 쓰임입니다. 문을 1초 동안 여는 연출이라면 t = 지난 시간 ÷ 1초 로 넣습니다. 0.5초가 지나면 t = 0.5 라서 반쯤 열려 있습니다.
Lerp 는 t 를 0 ~ 1 로 잘라 쓰므로 1 을 넘겨도 b 를 지나치지 않습니다. 자르지 않으려면 LerpUnclamped 를 씁니다.
매 프레임 지금 자리를 a 로 넣으면
화면에 그리는 그림 한 장이 프레임이고, fps 는 1초에 그리는 프레임 수입니다. 60fps 면 1초에 Update 가 60번 불립니다.
카메라 코드는 a 를 고정하지 않고 매 프레임 지금 자리를 넣습니다. 그러면 뜻이 “남은 거리의 10% 를 간다” 로 바뀝니다.
void LateUpdate()
{
// LateUpdate 는 모든 Update 가 끝난 뒤 불린다. 플레이어가 움직인 뒤에 따라가려고 여기에 둔다
// 지금 자리에서 목표까지 남은 거리의 10% 만큼 다가간다
transform.position = Vector3.Lerp(transform.position, target.position, 0.1f);
}
처음이 0, 목표가 10 이면 이렇게 됩니다.
| 프레임 | 남은 거리 | 이번에 간 양 (남은 거리 × 0.1) | 간 뒤 위치 | 간 뒤 남은 거리 |
|---|---|---|---|---|
| 1 | 10 | 1 | 1 | 9 |
| 2 | 9 | 0.9 | 1.9 | 8.1 |
| 3 | 8.1 | 0.81 | 2.71 | 7.29 |
10% 를 가면 90% 가 남습니다. 그래서 남은 거리는 프레임마다 0.9 배가 됩니다. 10 → 9 → 8.1 → 7.29 입니다. 처음엔 많이 가다가 가까워질수록 조금씩 가는, 부드럽게 다가가는 움직임이 이것입니다.
같은 수를 여러 번 곱하는 것: 거듭제곱
0.9 를 세 번 곱한 0.9 × 0.9 × 0.9 를 짧게 0.9³ 이라고 쓰고 “0.9 의 세제곱” 이라 읽습니다. 오른쪽 위의 작은 숫자가 몇 번 곱했나이고, 이 숫자를 지수라고 부릅니다. 위에 쓰기 어려운 곳에서는 0.9^3 처럼 ^ 뒤에 적습니다. 같은 수를 거듭 곱했다는 뜻으로 거듭제곱이라 합니다.
| 쓰는 법 | 뜻 | 값 |
|---|---|---|
| 0.9¹ | 0.9 | 0.9 |
| 0.9² | 0.9 × 0.9 | 0.81 |
| 0.9³ | 0.9 × 0.9 × 0.9 | 0.729 |
| 0.9¹⁵ | 0.9 를 15번 | 약 0.21 |
| 0.9⁶⁰ | 0.9 를 60번 | 약 0.0018 |
n 프레임 뒤에 남은 거리는 10 × 0.9ⁿ 입니다. 3 프레임 뒤면 10 × 0.729 = 7.29 로 위 표와 같습니다. 0.9 를 아무리 많이 곱해도 0 은 되지 않으니 정확히 닿지는 않습니다. 실제로는 남은 거리가 float 로 나타낼 수 있는 가장 작은 차이 근처까지 줄면, 더해 줄 10% 가 너무 작아 반올림으로 사라지고 목표 바로 앞에서 멈춰 버립니다.
n 이 프레임 수라서 fps 가 빠르기를 정한다
문제는 지수 n 이 시간이 아니라 프레임 수라는 것입니다. 같은 0.5초 동안 기계마다 곱하는 횟수가 다릅니다.
| 기계 | 0.5초 동안 프레임 | 남은 거리 |
|---|---|---|
| 30fps | 15번 | 10 × 0.9¹⁵ ≈ 2.1 |
| 120fps | 60번 | 10 × 0.9⁶⁰ ≈ 0.02 |
0.5초 뒤에 30fps 카메라는 아직 2.1 이나 뒤처져 있고, 120fps 카메라는 이미 붙어 있습니다.
deltaTime 을 곱해도 딱 맞지는 않는다
흔히 하는 고침은 0.1 대신 속도 * Time.deltaTime 을 넣는 것입니다. Time.deltaTime 은 지난 프레임이 걸린 초로, 60fps 면 1/60 ≈ 0.0167초, 30fps 면 1/30 ≈ 0.033초입니다. 프레임이 짧으면 조금, 길면 많이 가게 하려는 것입니다.
많이 나아지지만 아직 어긋납니다. 눈으로 보기 쉽게 프레임을 아주 크게 잡은 예입니다. 속도는 1, 목표까지 10 입니다.
| 0.5초를 | 한 프레임 비율 (1 × deltaTime) | 남는 비율 | 0.5초 뒤 남은 거리 |
|---|---|---|---|
| 0.5초 한 프레임으로 | 0.5 | 0.5 | 10 × 0.5 = 5 |
| 0.25초 두 프레임으로 | 0.25 | 0.75, 두 번 | 10 × 0.75 × 0.75 = 5.625 |
같은 0.5초인데 남은 거리가 5 와 5.625 로 다릅니다.
deltaTime 을 곱하면 가는 비율의 합은 0.25 + 0.25 = 0.5 로 맞춰집니다. 그런데 남은 거리는 비율을 더해서가 아니라 남는 비율을 곱해서 줄어듭니다. 1 − 0.5 = 0.5 와 (1 − 0.25) × (1 − 0.25) = 0.5625 는 다른 수입니다.
실제 fps 에서도 같습니다. 속도 6 이면 60fps 한 프레임은 1 − 0.1 = 0.9 가 남고, 120fps 두 프레임은 0.95 × 0.95 = 0.9025 가 남습니다. 차이가 작을 뿐 남아 있습니다.
1초에 남는 비율을 정하고, deltaTime 만큼 제곱한다
곱셈으로 줄어드는 것은 처음부터 “1초 동안 남는 비율” 로 정하면 됩니다. 예를 들어 1초에 남은 거리가 4분의 1 로 줄게 하려면 이 비율을 r = 0.25 로 둡니다.
- 2초 뒤에는 0.25 를 두 번 곱해 0.25² = 0.0625 가 남습니다
- 그럼 0.5초 뒤에는 무엇이 남아야 할까요. 0.5초를 두 번 하면 1초이니, 두 번 곱해서 0.25 가 되는 수여야 합니다. 0.5 × 0.5 = 0.25 이니 0.5 입니다
- 이것을 지수에 0.5 를 넣어 0.25^0.5 = 0.5 라고 씁니다(위첨자를 쓰기 어려워
^뒤에 지수를 적었습니다). 지수가 꼭 1, 2, 3 같은 횟수일 필요는 없고 “몇 초 치” 를 넣어도 됩니다 - 0.25초 뒤라면 네 번 곱해서 0.25 가 되는 수, 0.25^0.25 ≈ 0.707 입니다. 0.707 × 0.707 = 0.5 이고, 그것을 또 두 번 곱하면 0.5 × 0.5 = 0.25 입니다
정리하면 dt 초 동안 남는 비율 = r 의 dt 제곱(r^dt) 입니다. 이렇게 정하면 0.5초를 어떻게 나눠도 결과가 같습니다.
| 0.5초를 | 한 프레임 동안 남는 비율 | 0.5초 뒤 남는 비율 |
|---|---|---|
| 한 프레임으로 | 0.25^0.5 = 0.5 | 0.5 |
| 두 프레임으로 | 0.25^0.25 ≈ 0.707 | 0.707 × 0.707 = 0.5 |
| 네 프레임으로 | 0.25^0.125 ≈ 0.841 | 0.841 × 0.841 × 0.841 × 0.841 = 0.5 |
거듭제곱은 곱하면 지수끼리 더해집니다. 0.9² × 0.9³ 은 0.9 를 2번, 3번 곱한 것을 이어 곱했으니 0.9⁵ 입니다. 그래서 r^0.25 × r^0.25 = r^0.5 가 늘 성립합니다. 앞 절의 deltaTime 곱하기는 “가는 비율을 더한다” 에 맞췄다면, 이 방법은 “남는 비율을 곱하면 시간이 더해진다” 에 맞춘 것입니다. 남은 거리가 실제로 곱셈으로 줄어드니 이쪽이 맞습니다.
Mathf.Pow(a, b) 가 a 의 b 제곱입니다. 코드로는 이렇습니다.
// 1초 동안 남은 거리의 몇 배가 남나. 0.25 면 1초 뒤에 4분의 1만 남는다
public float remainPerSecond = 0.25f;
void LateUpdate()
{
// 이번 프레임(deltaTime 초) 동안 남는 비율
float remain = Mathf.Pow(remainPerSecond, Time.deltaTime);
// 가는 비율은 1 에서 남는 비율을 뺀 것
float t = 1f - remain;
transform.position = Vector3.Lerp(transform.position, target.position, t);
}
1 − Exp(−k × deltaTime) 은 같은 식이다
실제 코드와 자료에서는 위 식을 거의 1f - Mathf.Exp(-k * Time.deltaTime) 모양으로 씁니다. 같은 식을 다른 손잡이로 쓴 것입니다.
Mathf.Exp(x)는 e 의 x 제곱입니다. e 는 2.718… 로 정해진 수이고, 컴퓨터가 거듭제곱을 계산할 때 기본으로 쓰는 밑(거듭 곱하는 수)이라 함수가 따로 있습니다- e^(−k × dt) 는 e^(−k) 를 dt 제곱한 것과 같습니다. 거듭제곱을 또 거듭제곱하면 지수끼리 곱해지기 때문입니다. (0.9²)³ 은 0.9² 를 세 번 곱한 것이라 0.9 를 6번 곱한 0.9⁶ 입니다. 곧 r = e^(−k) 로 둔 r^dt 입니다
- 그래서 k 는 “e 를 몇 제곱해야 1초에 남는 비율 r 이 되나” 의 부호를 뒤집은 수입니다. e^(−1.39) ≈ 0.25 라서 r = 0.25 면 k = 1.39 입니다. 코드로는
k = -Mathf.Log(r)로 구합니다(Mathf.Log는 Exp 를 거꾸로 푸는 함수) - k 가 클수록 남는 비율이 작아져 빨리 따라갑니다
| k | 1초에 남는 비율 e^(−k) | 남은 거리가 절반이 되는 시간 (0.693 ÷ k) |
|---|---|---|
| 1.39 | 0.25 | 0.5초 |
| 6.32 | 0.0018 | 0.11초 |
| 13.9 | 0.000001 | 0.05초 |
표의 0.693 은 e^(−0.693) = 0.5 라서 나온 수입니다. k × 시간 이 0.693 이 되는 때가 절반이 남는 때입니다. 0.25 보다 “1.39” 가 덜 직관적으로 보이지만, k 는 0 에서 끝없이 키울 수 있고 클수록 빠르다는 방향이 하나라 조절하기 편합니다. “절반이 되는 시간” 으로 감을 잡아도 됩니다. k = 6.32 는 처음 코드의 0.1 과 맞춘 값입니다. 60fps 한 프레임에 e^(−6.32 ÷ 60) ≈ 0.9 가 남아 10% 를 갑니다. 1초에 남는 0.0018 도 0.9⁶⁰ 과 같습니다.
// 클수록 빨리 따라간다. 6.32 면 60fps 에서 한 프레임에 10%
public float sharpness = 6.32f;
void LateUpdate()
{
float t = 1f - Mathf.Exp(-sharpness * Time.deltaTime);
transform.position = Vector3.Lerp(transform.position, target.position, t);
}
지난 시간을 지수에 넣어 줄여 가는 이 방식을 지수 감쇠(exponential decay)라고 부릅니다. 아래는 세 방법을 30 · 60 · 120fps 에 같이 건 것입니다. 셋 다 60fps 에서는 한 프레임에 남은 거리의 10% 를 가도록 맞췄습니다.
정해진 속도로 가려면 MoveTowards, 스프링처럼 서려면 SmoothDamp
Lerp 로 따라가면 멀수록 빠르고 가까울수록 느려서 끝에 닿지 않습니다.
일정한 속도로 가서 정확히 멈추게 하고 싶으면 MoveTowards 를 씁니다.
public float speed = 5f; // 1초에 5
void Update()
{
// 목표 쪽으로 최대 speed × deltaTime 만큼. 목표를 지나치지 않고 그 자리에서 멈춘다
transform.position = Vector3.MoveTowards(transform.position, target.position, speed * Time.deltaTime);
}
목표까지 10 이면 정확히 2초 뒤에 닿고 멈춥니다. 한 번에 갈 거리를 “남은 거리의 몇 %” 가 아니라 “최대 몇 m” 로 주기 때문에, 곱셈이 아니라 더하기로 쌓입니다. 그래서 deltaTime 을 곱하는 것만으로 fps 와 상관없어집니다.
스프링처럼 부드럽게 출발하고 서고 싶으면 SmoothDamp 입니다. 속도를 따로 들고 있다가, 멀면 속도를 올리고 가까워지면 줄여서 미끄러지듯 멈춥니다. Lerp 따라가기는 출발하는 순간 속도가 가장 빠르지만 SmoothDamp 는 속도가 서서히 붙습니다.
Vector3 velocity; // SmoothDamp 가 매 프레임 고쳐 쓰는 속도. 필드로 둔다
public float smoothTime = 0.3f; // 대략 이 시간 정도에 걸쳐 따라붙는다
void LateUpdate()
{
transform.position = Vector3.SmoothDamp(transform.position, target.position, ref velocity, smoothTime);
}
velocity 를 지역 변수로 두면 매 프레임 0 으로 돌아가 속도가 쌓이지 않습니다. SmoothDamp 는 안에서 Time.deltaTime 을 쓰므로 따로 곱하지 않습니다.
회전도 같습니다. Quaternion.Slerp 는 두 회전 사이를 t 비율만큼 돌린 회전을 주는 회전용 Lerp 입니다. Quaternion.Slerp(지금 회전, 목표, 0.1f) 를 매 프레임 부르면 같은 문제가 생기니 비율을 지수 감쇠로 주고, 일정한 각속도로 돌리려면 Quaternion.RotateTowards 를 씁니다.
무엇을 쓸까
| 하고 싶은 것 | 쓸 것 | 끝에 닿나 |
|---|---|---|
| 정해진 시간 동안 A 에서 B 로 | Lerp(a, b, 지난 시간 ÷ 걸릴 시간) |
닿음 |
| 일정한 속도로 가서 멈추기 | MoveTowards |
닿음 |
| 멀면 빨리, 가까우면 천천히 | Lerp + 1 − Exp(−k × dt) |
거의 |
| 서서히 출발하고 서서히 서기 | SmoothDamp |
거의 |
| (피할 것) | 매 프레임 Lerp(x, 목표, 0.1f) |
거의, fps 따라 빠르기 다름 |
“거의” 인 둘은 남은 거리가 아주 작아지면 목표 자리를 그대로 넣어 끝내도 됩니다.
정리
Lerp(a, b, t)는 a 와 b 사이를 t 비율만큼 간 자리,a + (b − a) × t입니다. a 와 b 를 고정하고 t 를 0 에서 1 로 늘리는 것이 원래 쓰임입니다- 매 프레임 지금 자리를 a 로 넣고 0.1 을 주면 남은 거리가 프레임마다 0.9 배가 됩니다. n 프레임 뒤엔 0.9ⁿ 이라, 1초에 몇 번 곱하느냐(fps)가 빠르기를 정합니다
- deltaTime 을 곱하면 가는 비율의 합만 맞습니다. 남은 거리는 남는 비율의 곱으로 줄어서 여전히 조금 어긋납니다
- 1초에 남는 비율 r 을 정하고 한 프레임에 r^deltaTime 이 남게 하면, 시간을 어떻게 나눠도 같습니다. 거듭제곱은 곱하면 지수가 더해지기 때문입니다
- 코드로는 비율
1 − Mathf.Exp(−k × Time.deltaTime). k 가 클수록 빨리 따라갑니다 - 일정한 속도로 정확히 멈추려면
MoveTowards, 서서히 붙고 서려면SmoothDamp입니다