콘텐츠로 건너뛰기
Codex

setInterval로 만든 애니메이션이 미세하게 떨릴 때

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분이었다.

이 블로그 더 보기

이 블로그는 실제 프로젝트를 AI와 함께 굴리면서 남긴 기록입니다.

  • 새 글은 RSS로 받아볼 수 있습니다.
  • 시리즈 전체는 여기에 정리해 두었습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다