TECH NOTE · AUTOMATION OPS
setInterval 33ms 틱과 60Hz 화면의 위상이 어긋나 애니메이션이 떨립니다. 실측으로 원인을 잡고 rAF로 옮긴 과정입니다.
코드도 그림도 멀쩡한데 계속 떨린다
바탕화면 캐릭터가 걸을 때 좌우로 미세하게 떨렸다. 크게 흔들리는 게 아니라, 보고 있으면 어딘가 거슬리는 정도다.
스프라이트를 의심했다. 프레임마다 캐릭터 위치가 1픽셀씩 다른가 싶어 전부 맞췄다. 안 고쳐졌다.
이동 계산을 의심했다. 소수점 좌표를 반올림하는 지점에서 왕복하나 싶어 임계값을 걸었다. 그것도 아니었다.
원인은 타이머 주기였다.
60fps로 녹화해서 프레임을 셌다
눈으로는 “떨린다”까지밖에 안 나온다. 화면을 60fps로 녹화하고 걷는 구간을 프레임 단위로 넘겨 봤다.
두 가지가 보였다.
- 창이 한 프레임 안 움직이는 순간이 주기적으로 있다 (멈칫)
- 그 다음 프레임에서 평소보다 크게 움직인다 (따라잡기)
세어 보니 걷는 중 초당 4.90회 멈칫하고 4.07회 따라잡았다. 12초 녹화에 50여 회다.
이 정도면 사람 눈에 “떨림”으로 보인다. 위치가 틀린 게 아니라 속도가 일정하지 않은 것이었다.
33ms와 16.7ms는 안 맞는다
이동 틱을 setInterval로 33ms마다 돌리고 있었다.
const LOCOMOTION_TICK_MS = 33;
locomotionTimer = window.setInterval(() => {
void tickLocomotion();
}, LOCOMOTION_TICK_MS);
60Hz 화면은 16.7ms마다 한 번 그린다. 33ms는 16.7ms의 정수 배가 아니다.
프레임 경계 0 16.7 33.3 50.0 66.7 83.3 100.0
틱 0 33.0 66.0 99.0
↑ ↑ ↑ ↑
프레임에 1.3ms 못 0.7ms 못 1.0ms 못
딱 맞음 미침 미침 미침
틱이 프레임 경계보다 조금씩 앞서다가, 어느 순간 경계를 넘긴다. 그때 그 프레임은 새 위치를 못 받고 이전 위치 그대로 그려진다. 다음 틱은 경과 시간이 그만큼 길어 dt 비례로 더 크게 이동한다.
이걸 케이던스 지터라고 부른다. 위상이 어긋난 두 주기가 만나면 생기는 현상이라, 값을 아무리 정확히 계산해도 사라지지 않는다.
프레임에 맞춰 돌린다
고치는 방향은 하나다. 틱을 화면이 그리는 시점에 붙인다.
function draw(now) {
// 로코모션 틱을 컴포지터 프레임에 정렬한다.
if (locomotionTimer !== null && now - lastLocomotionAt >= LOCOMOTION_RAF_MIN_MS) {
void tickLocomotion();
}
// ...그리기...
requestAnimationFrame(draw);
}
requestAnimationFrame은 화면이 그려지기 직전에 불린다. 여기서 위치를 갱신하면 그 값이 곧바로 그 프레임에 반영된다. 위상이 어긋날 자리가 없다.
최소 간격을 둔 이유는 주사율 때문이다.
const LOCOMOTION_RAF_MIN_MS = 15;
60Hz에서는 매 프레임(16.7ms) 틱이 돈다. 120Hz(ProMotion)에서는 8.3ms마다 콜백이 오는데, 15ms 조건 때문에 두 번에 한 번만 실제로 돈다. 결과적으로 두 화면 모두 약 16.7ms 주기가 유지된다.
틱이 30Hz에서 60Hz로 빨라지면서 걸음의 계단도 절반이 됐다. 한 틱에 2물리픽셀 움직이던 것이 1픽셀이 된다. 지터 진폭 자체가 절반으로 줄었다.
`setInterval`을 지우면 안 된다
requestAnimationFrame은 창이 가려지거나 탭이 백그라운드로 가면 멈춘다. 그러면 캐릭터도 멈춘다.
그래서 기존 인터벌을 백스톱으로 남겼다.
const LOCOMOTION_BACKSTOP_TICK_MS = 96;
locomotionTimer = window.setInterval(() => {
if (performance.now() - lastLocomotionAt < LOCOMOTION_TICK_MS - 2) return;
void tickLocomotion();
}, LOCOMOTION_BACKSTOP_TICK_MS);
rAF가 정상으로 돌 때는 이 인터벌이 항상 아무것도 안 한다. 15ms마다 틱이 돌고 있으니 lastLocomotionAt이 계속 갱신되어 조건에 안 걸린다.
rAF가 멈춘 동안에만 실제로 틱을 만든다. 96ms 주기를 고른 건 물리 계산의 dt 상한이 100ms이기 때문이다. 백스톱만 도는 상황에서도 상한에 안 걸려 시간이 버려지지 않는다.
결과
같은 방식으로 다시 녹화해서 셌다.
| 항목 | 고치기 전 | 고친 뒤 |
|---|---|---|
| 멈칫 | 4.90회/s | 0.97회/s |
| 따라잡기 점프 | 4.07회/s | 1.10회/s |
0이 아닌 건 다른 원인이 남아서다. 하지만 눈에 보이던 떨림은 사라졌다.
어디서 또 나오나
주기가 다른 두 시스템이 만나는 곳이면 어디든 난다.
setInterval로 만든 애니메이션 (거의 전부 해당)- 오디오 콜백과 화면 갱신을 같이 쓰는 코드
- 서버 폴링 주기와 화면 갱신 주기가 다를 때
화면에 보이는 것을 움직인다면 requestAnimationFrame이 기본값이어야 한다. setInterval은 화면과 무관한 주기 작업(저장, 하트비트, 폴링)에 쓴다.
CSS 애니메이션이나 Web Animations API를 쓸 수 있으면 그게 더 낫다. 컴포지터가 직접 처리해서 메인 스레드 지연에도 안 흔들린다. 다만 이 경우는 OS 창 자체를 움직이는 거라 해당이 안 됐다.
정리
- 주기가 다른 두 시스템(33ms 틱과 16.7ms 프레임)이 만나면 위상이 어긋나 떨림이 생긴다
- 값이 틀린 게 아니라 속도가 일정하지 않은 것이라, 계산을 아무리 봐도 안 나온다
- 눈으로 판단하지 말고 60fps로 녹화해 프레임을 센다. 멈칫과 따라잡기가 짝으로 보인다
- 화면에 보이는 움직임은
requestAnimationFrame에 붙인다 - 주사율이 다양하므로 최소 간격을 둔다. 120Hz에서 두 배로 안 돌게
- rAF는 창이 가려지면 멈춘다. 인터벌 백스톱을 남기되 정상일 때는 no-op이어야 한다
떨림을 스프라이트와 반올림에서 찾다가 이틀을 썼다. 녹화해서 프레임을 센 게 30분이었다.