배경
지난 글에서 세 줄짜리 오일러 적분으로 포탄의 포물선 운동을 구현했다.
vy += GRAVITY;
x += vx;
y += vy;
그런데 이 코드는 그대로 쓰면 컴퓨터마다 포탄 속도가 달라진다. 내 컴퓨터에서는 정상인데 다른 모니터에서는 착탄 지점이 어긋난다. 이 글에서는 그 원인과 해결책인 deltaTime을 다룬다.
같은 코드가 모니터마다 다르게 실행된다
문제는 "매 프레임"에 있다. requestAnimationFrame은 모니터가 화면을 새로 그릴 때마다 루프를 한 번 호출한다. 60Hz 모니터에서는 초당 60번, 144Hz 모니터에서는 초당 144번이다. 같은 코드라도 실행 횟수가 모니터마다 다르다.
GRAVITY를 1이라고 두고, 1초 동안 vy += GRAVITY가 몇 번 실행되는지 세어보면 차이가 드러난다.
| 60Hz 모니터 | 144Hz 모니터 | |
|---|---|---|
| 1초간 루프 실행 횟수 | 60번 | 144번 |
| 1초 뒤 vy | 1 × 60 = 60 | 1 × 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/60 | GRAVITY × 1/144 |
| 1초간 루프 실행 횟수 | 60번 | 144번 |
| 1초 뒤 vy | GRAVITY | GRAVITY |
144Hz는 자주 실행되는 대신 한 번에 더하는 양이 그만큼 작다. 횟수와 한 번에 더하는 양이 상쇄되어, 1초 뒤 결과는 어느 모니터에서나 같다. 144Hz 쪽은 같은 궤적을 더 잘게 쪼개서 부드럽게 그릴 뿐이다.
정리하면 dt 없는 코드는 "1프레임당 얼마"로 정의되어 프레임 수에 따라 결과가 달라지고, dt를 곱한 코드는 "1초당 얼마"로 정의되어 프레임 수와 무관하다. GRAVITY의 단위도 픽셀/프레임²에서 픽셀/초²로 바뀐다.
GRAVITY 값은 게임에 맞게 튜닝한다
단위가 픽셀/초²가 되었으니 GRAVITY에 어떤 값을 넣을지 정해야 한다. 현실 중력 9.8을 그대로 쓰지 않고 픽셀 단위로 튜닝한다. 보통 400~800px/s² 사이에서 포탄이 적당히 뜨고 떨어지는 값을 찾는다. 숫자 하나가 게임 손맛을 결정한다.
정리
requestAnimationFrame은 모니터 주사율만큼 실행된다. 프레임당 고정값을 더하면 60Hz와 144Hz에서 다른 게임이 된다.- dt를 곱하면 "1프레임당 얼마"가 "1초당 얼마"로 바뀐다. 실행 횟수와 한 번에 더하는 양이 상쇄되어 어떤 모니터에서도 같은 궤적이 나온다.
- GRAVITY 단위는 픽셀/초²가 되고, 값은 게임 손맛에 맞게 튜닝한다.
바람과 탄도 예측선도 같은 원리의 응용이다. 바람은 수평 방향 가속도이고, 예측선은 같은 계산을 렌더링 없이 미리 돌려보는 것이다. 이건 다음 글에서 다룬다.