본문으로 건너뛰기

Lerp로 요소 움직임 부드럽게 만들기

·5 min read

마우스를 따라다니는 원을 만들 때, mousemove 좌표를 그대로 transform에 박으면 커서에 딱 붙어서 움직인다. 끌려오는 듯한 부드러움이 없었다. 그때 Lerp(Linear Interpolation, 선형 보간)를 처음 써봤다.

선형 보간, 두 점 사이를 비율로 나눈다

선형 보간은 두 점 사이의 값을 직선 거리에 따라 추정하는 방법이다. 시작점 좌표와 끝점 좌표를 알고 있을 때, 그 둘을 잇는 직선 위에서 어느 지점의 값을 비율로 나눠 구한다.

코드로 옮기면 함수 하나로 끝난다.

// s: 시작 지점
// e: 끝 지점
// a: 보간 비율 (0~1 사이)
// 계산식: s + (e - s) * a
 
function lerp(s, e, a) {
  return s + (e - s) * a
}

a가 0이면 시작점, 1이면 끝점이 그대로 나온다. 그 사이 값(0.1, 0.5 등)을 넣으면 시작과 끝 사이의 어느 지점이 반환된다.

공식이 부드러워 보이는 이유 — 매 프레임 남은 거리의 일부만 이동

이 함수를 requestAnimationFrame의 콜백 안에서 반복 호출하면, 매 프레임마다 현재 위치(s)와 목표 지점(e) 사이의 거리 중 a 비율만큼만 이동한다. 비율을 0.1로 잡으면 매 프레임 남은 거리의 10%만 따라간다.

처음엔 거리가 멀어서 한 프레임에 크게 움직이고, 목표에 가까워질수록 남은 거리가 작아져 이동량도 작아진다. 끝에 가까울수록 느려지는 ease-out 같은 곡선이 공식에서 그냥 따라나온다. 별도 가속도 계산이 필요 없었다.

requestAnimationFrame은 브라우저가 다음 리페인트 직전에 콜백을 실행해준다(React의 setInterval과 달리 화면 갱신 타이밍과 맞춰져서 끊김이 적다). 그래서 매 프레임마다 한 칸씩 보간한 좌표를 transform에 적용하면 부드럽게 따라오는 효과가 난다.

마우스를 따라다니는 원으로 확인한 동작

전체 흐름을 한 번 그려보면 mousemove로 목표 좌표(mouseX, mouseY)를 계속 갱신해두고, requestAnimationFrame 루프 안에서 현재 좌표(startX, startY)를 목표 좌표 쪽으로 lerp로 살짝씩 끌어당기는 구조다.

flowchart LR
    Mouse[mousemove 이벤트] --> Target["목표 좌표<br/>mouseX, mouseY 갱신"]
    Target --> Loop["requestAnimationFrame 루프"]
    Loop --> Lerp["lerp(start, target, 0.1)"]
    Lerp --> Apply["transform 적용"]
    Apply --> Loop

실제 HTML 코드는 이렇게 짰다. 빨간 원이 화면 어딘가에서 시작해, 커서를 천천히 쫓아오게 된다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <style>
      #circle {
        position: absolute;
        width: 50px;
        height: 50px;
        top: -25px;
        left: -25px;
        border-radius: 50%;
        background-color: red;
      }
    </style>
    <title>Document</title>
  </head>
  <body>
    <div id="circle"></div>
 
    <script>
      const circle = document.getElementById("circle");
      let mouseX = 0,
        mouseY = 0,
        startX = 0,
        startY = 0;
 
      window.addEventListener("mousemove", (e) => {
        mouseX = e.clientX;
        mouseY = e.clientY;
        circle.style.transform = `translate(${mouseX}px, ${mouseY}px)`;
      });
 
      function frame() {
        requestAnimationFrame(frame);
 
        startX = lerp(startX, mouseX, 0.1);
        startY = lerp(startY, mouseY, 0.1);
 
        circle.style.transform = `translate(${startX}px, ${startY}px)`;
      }
      requestAnimationFrame(frame);
 
      function lerp(s, e, a) {
        return s + (e - s) * a;
      }
    </script>
  </body>
</html>

mousemove 안에서도 한 번 transform을 갱신하지만, 그건 즉각적인 위치 표시고, 실제로 부드럽게 따라오는 움직임은 frame() 안의 lerp 보간이 만든다. 보간 비율 0.1을 0.05로 낮추면 더 느리게 끌려오고, 0.3으로 올리면 거의 즉시 붙는다.