프레임마다 8px의 함정
다음 프레임을 요청하지 않으면 그림은 사라질까?에서 남긴 다음 질문이 이번 주제였다. 프레임마다 같은 거리만큼 움직이면 기기에 따라 속도가 달라질 텐데, 시간을 기준으로 위치를 계산하려면 어떻게 해야 할까.
학습 예제는 사각형 둘을 나란히 두고 이동 규칙만 다르게 한 것이었다.
blueX += 8; // 프레임당 8px
orangeX += 480 * dt; // 초당 480px
아래 버튼을 누르면 두 사각형이 출발합니다. 60fps에서는 나란히 가므로, 제한을 낮춰야 차이가 드러납니다.
파랑은 x += 8 — 프레임당 고정. 주황은 x += 480 * dt — 초당 480px.
결승선(점선)까지 달려 도착 시각을 남깁니다. 주황은 화면이 빠르든 느리든 늘 같은 시각에 도착합니다. 파랑의 속도 단위는 "초당"이 아니라 "프레임당"이라, 프레임이 덜 오면 덜 움직이고 120Hz 화면에서는 반대로 두 배 빨라집니다.
60fps에서는 둘이 나란히 간다. 8 × 60 = 480이라 초당 이동 거리가 같기 때문이다. 그래서 60fps만 보면 두 코드의 차이를 알 수 없다. 프레임 속도를 30fps, 15fps로 낮추면 파랑만 느려진다. 파랑의 속도 단위는 "초당"이 아니라 "프레임당"이어서, 프레임이 덜 오면 그만큼 덜 움직인다.
x += 8은 속도를 정한 것이 아니라 프레임이 초당 몇 번 오는지를 가정한 것이다. 그 가정은 기기·창 상태·다른 탭의 부하에 따라 언제든 깨진다.
깨지는 방향은 한쪽이 아니다. 위 예제를 120Hz 화면에서 돌렸더니 파랑이 0.73초, 주황이 1.46초에 결승선에 닿았다. 프레임이 두 배로 오니 파랑이 두 배 빨라진 것이다. 반대로 15fps로 제한하면 주황이 도착한 뒤에도 파랑은 한참 뒤에 남아 있다. 느려지기만 하는 문제가 아니라, 화면이 몇 Hz인지에 속도가 통째로 끌려다니는 것이다.
dt는 무엇의 차이인가
처음 dt가 무엇이냐는 질문에 "흘러간 시간"이라고 답했다. 방향은 맞지만 시작부터 흘러간 시간인지 직전부터 흘러간 시간인지가 갈린다. 이 구분이 전부다.
const dt = (now - lastUpdate) / 1000; // 초 단위
lastUpdate = now; // 기준점을 지금으로 갱신
now는 이번 프레임의 시각, lastUpdate는 직전에 그린 프레임의 시각이다. 바로 다음 줄에서 기준점을 지금으로 덮어쓰기 때문에, dt는 매번 직전 프레임부터 현재까지 걸린 시간이 된다. 시작 시각은 별도 변수이고 dt와는 다른 값이다.
/1000은 단위를 맞추는 일이다. requestAnimationFrame이 넘겨주는 시각은 밀리초라서, 속도를 "초당 480px"로 잡았다면 초로 바꿔야 곱이 맞는다.
이름 자체가 답을 담고 있다. dt는 delta time의 줄임이고, d는 그리스 문자 Δ(delta) — 수학·물리에서 변화량·차이를 뜻한다. 즉 이름이 이미 "뺄셈의 결과"라고 말하고 있어서, 누적 시간이 아니라는 게 드러나 있다.
여기서 기억할 것dt는 직전 프레임부터 현재까지 걸린 시간이다. 누적 경과 시간이 아니다.
왜 곱하면 상쇄되는가
60fps면 dt ≈ 0.0167초, 30fps면 dt ≈ 0.0333초다. 프레임 속도가 반이 되면 dt는 두 배가 된다. 반비례한다.
그래서 속도 × dt를 더하면 한 번에 더해지는 양이 커지는 대신 더하는 횟수가 줄어, 곱이 서로 상쇄된다. 1초 동안의 합계는 어느 쪽이든 480px이다.
| 프레임 속도 | 1회 이동량 | 1초간 횟수 | 1초 합계 | |---|---|---|---| | 60fps | 8px | 60회 | 480px | | 30fps | 16px | 30회 | 480px | | 15fps | 32px | 15회 | 480px |
나는 이것을 "60번 쪼개서 계산하냐, 30번 쪼개서 계산하냐의 차이일 뿐"이라고 설명했다. 나눠 담는 횟수가 달라질 뿐 총량은 같다.
이때 "보간"이라는 표현을 썼는데, 보간(interpolation)은 보통 두 값 사이의 중간값을 만들어 내는 일을 가리킨다. 여기서 일어나는 것은 그보다 상쇄·보상에 가깝다. 용어만 구분해 둔다.
영어로 프레임 독립 이동 설명하기
첫 시도는 Multiply movement by elapsed time for object same speed differently frame speed.였다. 동사구는 맞았지만 뒤쪽이 명사 나열로 무너졌다.
Multiply the movement by the elapsed time so that the object moves at the same speed even when the frame rate changes.
고친 곳은 관사, 목적을 절로 푸는 so that, 양보를 나타내는 even when ~ changes, 그리고 용어다. 초당 프레임 수는 관용적으로 frame rate이고 frame speed라고 쓰지 않는다. 영어 메모에 첫 문장과 교정 이유를 남겼다.
참고 문장을 보며 위 문장을 정확히 다시 작성했다. 예문 없는 독립 회상은 이후에 확인한다.
다음에 확인할 질문은 "Three.js에서 같은 dt를 어디서 받아 오고, 화면에 물체 하나를 띄우려면 무엇부터 준비해야 할까"다.