매 프레임 Lerp(a, b, 0.1f) 를 쓰면 생기는 일
Esc

    매 프레임 Lerp(a, b, 0.1f) 를 쓰면 생기는 일

    –Unity기초

    카메라가 플레이어를 부드럽게 따라가게 하려고 매 프레임 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 입니다