프레임 독립적인 게임 만들기

2026-07-29 · 3 min read

배경

지난 글에서 세 줄짜리 오일러 적분으로 포탄의 포물선 운동을 구현했다.

vy += GRAVITY;
x += vx;
y += vy;

그런데 이 코드는 그대로 쓰면 컴퓨터마다 포탄 속도가 달라진다. 내 컴퓨터에서는 정상인데 다른 모니터에서는 착탄 지점이 어긋난다. 이 글에서는 그 원인과 해결책인 deltaTime을 다룬다.

같은 코드가 모니터마다 다르게 실행된다

문제는 "매 프레임"에 있다. requestAnimationFrame은 모니터가 화면을 새로 그릴 때마다 루프를 한 번 호출한다. 60Hz 모니터에서는 초당 60번, 144Hz 모니터에서는 초당 144번이다. 같은 코드라도 실행 횟수가 모니터마다 다르다.

GRAVITY를 1이라고 두고, 1초 동안 vy += GRAVITY가 몇 번 실행되는지 세어보면 차이가 드러난다.

60Hz 모니터144Hz 모니터
1초간 루프 실행 횟수60번144번
1초 뒤 vy1 × 60 = 601 × 144 = 144

1초 뒤 낙하 속도가 60 대 144로 벌어진다. 144 ÷ 60 = 2.4, 포탄이 2.4배 빠르게 가속되는 것이다. 같은 코드, 같은 각도, 같은 파워인데 컴퓨터마다 착탄 지점이 달라진다. 대전 게임에서는 치명적이다.

해결책: 프레임 대신 시간을 기준으로 삼는다

프레임 사이에 흐른 실제 시간을 deltaTime, 줄여서 dt라고 부른다. 이 값을 곱해주면 된다.

let last = performance.now();

function loop(now) {
  const dt = (now - last) / 1000; // 지난 프레임 이후 흐른 시간(초)
  last = now;

  vy += GRAVITY * dt;
  x += vx * dt;
  y += vy * dt;

  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

dt는 직전 프레임 이후 흐른 시간이다. 60Hz에서는 프레임 간격이 1/60초, 144Hz에서는 1/144초다. 같은 계산을 다시 해보면 이번에는 결과가 같아진다.

60Hz 모니터144Hz 모니터
한 번에 더하는 양GRAVITY × 1/60GRAVITY × 1/144
1초간 루프 실행 횟수60번144번
1초 뒤 vyGRAVITYGRAVITY

144Hz는 자주 실행되는 대신 한 번에 더하는 양이 그만큼 작다. 횟수와 한 번에 더하는 양이 상쇄되어, 1초 뒤 결과는 어느 모니터에서나 같다. 144Hz 쪽은 같은 궤적을 더 잘게 쪼개서 부드럽게 그릴 뿐이다.

정리하면 dt 없는 코드는 "1프레임당 얼마"로 정의되어 프레임 수에 따라 결과가 달라지고, dt를 곱한 코드는 "1초당 얼마"로 정의되어 프레임 수와 무관하다. GRAVITY의 단위도 픽셀/프레임²에서 픽셀/초²로 바뀐다.

GRAVITY 값은 게임에 맞게 튜닝한다

단위가 픽셀/초²가 되었으니 GRAVITY에 어떤 값을 넣을지 정해야 한다. 현실 중력 9.8을 그대로 쓰지 않고 픽셀 단위로 튜닝한다. 보통 400~800px/s² 사이에서 포탄이 적당히 뜨고 떨어지는 값을 찾는다. 숫자 하나가 게임 손맛을 결정한다.

정리

  1. requestAnimationFrame은 모니터 주사율만큼 실행된다. 프레임당 고정값을 더하면 60Hz와 144Hz에서 다른 게임이 된다.
  2. dt를 곱하면 "1프레임당 얼마"가 "1초당 얼마"로 바뀐다. 실행 횟수와 한 번에 더하는 양이 상쇄되어 어떤 모니터에서도 같은 궤적이 나온다.
  3. GRAVITY 단위는 픽셀/초²가 되고, 값은 게임 손맛에 맞게 튜닝한다.

바람과 탄도 예측선도 같은 원리의 응용이다. 바람은 수평 방향 가속도이고, 예측선은 같은 계산을 렌더링 없이 미리 돌려보는 것이다. 이건 다음 글에서 다룬다.