문제: 내 라이브러리, 모바일에서 애니메이션이 뻑뻑하고 백그라운드 복귀 시 깨짐
해결: dt clamping + lag smoothing + 적절한 수치해석 선택
결과: 60fps → 불규칙 FPS 환경에서도 부드럽게 작동
CSS가 아닌 JavaScript 애니메이션 라이브러리의 핵심 구조:
시간축 다이어그램:
┌─────┬─────┬─────┬─────┐
│ dt1 │ dt2 │ dt3 │ dt4 │
└─────┴─────┴─────┴─────┘
16ms 16ms 16ms 16ms (이상적)
position 업데이트:
0 ──→ 16 ──→ 32 ──→ 48 ──→ 64
function animate() {
const currentTime = performance.now();
const dt = (currentTime - lastTime) / 1000; // delta time (초 단위)
position += velocity * dt; // dt만큼 값 업데이트
render(position); // 화면 갱신
lastTime = currentTime;
requestAnimationFrame(animate); // 재귀 호출
}
핵심: requestAnimationFrame(RAF)으로 브라우저의 화면 주사율에 맞춰 반복 실행하며, dt(delta time)만큼 값을 조작해 애니메이션을 진행시킨다.
자연스러운 애니메이션의 비밀은 물리 시뮬레이션:
F = ma = -kx - cv
k: stiffness (용수철 계수 - 얼마나 뻣뻣한가)
c: damping (저항 계수 - 얼마나 빨리 멈추는가)
x: 목표로부터의 거리
v: 속도
의미:
-kx)-cv)dt만큼 속도와 위치를 보정해서 다음 프레임을 계산하는 방법
가장 단순한 Euler 방법:
// 매 프레임마다
const acceleration = force / mass;
velocity += acceleration * dt;
position += velocity * dt;
문제는 어떤 방법으로 식을 세울 것인가가 성능과 안정성을 결정한다는 것!
데스크톱에서는 60fps(16.67ms)가 일반적이지만, 모바일은 완전히 다른 세계:
| 환경 | 평균 dt | 최대 dt | 비고 |
|---|---|---|---|
| 데스크톱 Chrome | 16.67ms | ~20ms | 안정적 |
| iPhone 13 | 16ms | 200ms | 백그라운드 복귀 시 |
| Galaxy S23 | 33ms | 150ms | 가변적 |
| 저사양 안드로이드 | 50-100ms | 500ms+ | 💀 |
// dt = 16ms: ✅ 정상 작동
// dt = 100ms: ⚠️ Spring이 과도하게 튐
// dt = 5000ms: 💥 완전히 폭발
실제 시나리오:
"모바일에서 뻑뻑하다는 피드백을 받고, 기존 라이브러리(오래된 버전)를 뜯어봤더니 가변 FPS 대응이 전혀 안 되어 있었다. dt가 100ms만 넘어가도 Spring이 미친 듯이 튀고, 백그라운드에서 돌아오면 애니메이션이 순간이동했다. 결국 직접 만들기로 결정."
교훈: 모바일은 데스크톱이 아니다. 16ms를 기대하지 말고, 500ms도 대응할 수 있게 만들어야 한다.
| 방법 | 안정성 | 정확도 | 성능 | 유연성 | 모바일 추천 |
|---|---|---|---|---|---|
| Explicit Euler | ❌ | ❌ | ✅✅ | ✅ | ❌ |
| Semi-Implicit Euler | ✅ | ⭐ | ✅ | ✅ | ✅✅ |
| RK4 (룽게 쿠타) | ⚠️ | ✅✅ | ⭐ | ✅ | ❌ |
| Verlet/Leapfrog | ✅ | ✅ | ✅ | ⭐ | ✅ |
| Analytical Solution | ✅✅ | ✅✅ | ✅✅ | ❌ | ✅✅ |
출처: Allen Chou - Game Math: Numeric Springing
사용처: Svelte Motion, 웹 애니메이션 라이브러리
// 정규화된 파라미터 사용
// ω (omega) = sqrt(k/m) : 각진동수 (얼마나 빨리 진동)
// ζ (zeta) = c/(2√km) : 감쇠비 (얼마나 빨리 멈춤)
void SpringSemiImplicitEuler(
float &x, // 현재 값
float &v, // 속도
float xt, // 목표 값
float zeta, // damping ratio
float omega, // angular frequency
float h // timestep (dt)
) {
v += -2.0f * h * zeta * omega * v + // 감쇠력
h * omega * omega * (xt - x); // 복원력
x += h * v; // 핵심: 속도 먼저, 위치 나중!
}
장점:
왜 Explicit Euler는 안 되는가:
// Explicit Euler (위치 먼저, 속도 나중)
position += velocity * dt;
velocity += acceleration * dt;
// → 진동 폭이 계속 커지는 오류 (에너지 증가)
출처: React Native Reanimated 소스코드
사용처: React Native Reanimated (네이티브)
// 미분방정식의 정확한 해를 직접 계산
const t = (currentTime - startTime) / 1000;
const envelope = Math.exp(-zeta * omega0 * t);
// Under-damped (ζ < 1, 진동하면서 멈춤)
const position = target + envelope *
(Math.sin(omega1 * t) * A + x0 * Math.cos(omega1 * t));
장점:
단점:
// RK4: 4단계 계산
k1 = f(t, x)
k2 = f(t + dt/2, x + k1*dt/2)
k3 = f(t + dt/2, x + k2*dt/2)
k4 = f(t + dt, x + k3*dt)
x += (k1 + 2*k2 + 2*k3 + k4) * dt / 6
문제점:
// dt = 16ms (일정): ✅ RK4 정확함
// dt = 16~100ms (가변): ❌ RK4 오차 심함
// dt = 5000ms (큼): 💥 RK4 완전히 망가짐
결론: 모바일 가변 FPS 환경에선 Semi-Implicit이나 Analytical이 훨씬 현명한 선택
문제 인식: 가변 FPS를 어떻게 처리할 것인가?
모바일에서 뻑뻑하다는 피드백을 받고, "국밥 애니메이션 라이브러리" GSAP의 코드를 뜯어봤다.
출처: GSAP Ticker 소스코드
dt = Math.min(dt, 0.1); // 100ms로 제한
목적: Spiral of Death 방지
느려짐 → dt 커짐 → 계산량 증가 → 더 느려짐 → 💥
// 백그라운드 복귀 등 비정상적으로 긴 시간 경과 시
if (elapsed > 500) { // lagThreshold
startTime += elapsed - 33; // adjustedLag
}
동작 원리:
// 백그라운드 5초 후 복귀
elapsed = 5000ms
// 보정 없으면: 애니메이션 5초치 점프 💥
// 보정 후: startTime 조정으로 33ms만 경과한 것처럼
startTime += (5000 - 33) = 4967ms 추가
// → 결과적으로 33ms만 진행된 것처럼 계산됨
내가 적용한 값:
const MAX_DT = 0.1; // 100ms
const LAG_THRESHOLD = 0.5; // 500ms
const ADJUSTED_LAG = 0.033; // 33ms
출처: GSAP Ticker adaptive scheduling
프레임 스케줄:
정상 상황:
┌──────┬──────┬──────┐
│ 16ms │ 16ms │ 16ms │
└──────┴──────┴──────┘
✓ ✓ ✓
프레임 드롭:
┌──────┬────────────┬────┐
│ 16ms │ 50ms │ 4ms│ ← 빠르게 스킵
└──────┴────────────┴────┘
✓ ✗ ✓
const overlap = time - nextTime; // 예정보다 얼마나 늦었는지
nextTime += overlap + (overlap >= gap ? 4 : gap - overlap);
동작 원리:
// gap = 16.67ms (60fps 목표)
// 조금 늦음 (overlap = 3ms)
nextTime += 3 + (16.67 - 3) = 16.67ms
// → 정상 간격 유지
// 많이 늦음 (overlap = 20ms)
nextTime += 20 + 4 = 24ms
// → 빠르게 스킵해서 따라잡기
효과: 프레임 드롭 시 자동으로 간격 조정
문제: requestAnimationFrame은 비싼 함수
// ❌ 나쁜 예
animation1: requestAnimationFrame(update1);
animation2: requestAnimationFrame(update2);
animation3: requestAnimationFrame(update3);
// → RAF 호출 3번
// ✅ 좋은 예: Ticker 싱글톤
class Ticker {
private listeners = new Set<(dt: number) => void>();
private tick = (timestamp: number) => {
const dt = (timestamp - this.lastTime) / 1000;
// 모든 애니메이션을 한 번에
this.listeners.forEach(callback => callback(dt));
if (this.listeners.size > 0) {
this.rafId = requestAnimationFrame(this.tick);
}
};
subscribe(callback: (dt: number) => void) {
this.listeners.add(callback);
if (this.listeners.size === 1) {
requestAnimationFrame(this.tick);
}
}
}
ticker.subscribe(animation1.update);
ticker.subscribe(animation2.update);
// → RAF 호출 1번만!
효과: RAF 호출 최소화, 배터리 절약
적용 사례: GSAP, Framer Motion, 내 라이브러리
문제: === 비교는 영원히 안 맞음
// ❌ 이렇게 하면 영원히 안 멈춤
if (position === target && velocity === 0) {
stop();
}
// → 부동소수점 오차로 딱 맞지 않음
// position = 99.999999...
// velocity = 0.0000001...
해결: 임계값(threshold) 사용
// ✅ 올바른 방법
const POSITION_THRESHOLD = 0.01;
const VELOCITY_THRESHOLD = 0.01;
const isSettled =
Math.abs(position - target) < POSITION_THRESHOLD &&
Math.abs(velocity) < VELOCITY_THRESHOLD;
if (isSettled) {
position = target; // 딱 맞춰주고
velocity = 0;
stop();
}
Before:
- 기존 라이브러리 사용 (오래된 버전)
- 모바일에서 뻑뻑함 💀
- 백그라운드 복귀 시 깨짐 💥
After (GSAP 코드 분석 후 적용):
- Semi-Implicit Euler 직접 구현
- dt clamping (100ms) 적용
- lag smoothing (500ms → 33ms) 적용
- adaptive frame rate 추가
- Ticker 싱글톤 패턴
Result:
- 저사양 기기에서도 부드러움 ✅
- 백그라운드 복귀 안정적 ✅
[ ] 수치해석 방법 선택
→ Semi-Implicit Euler (중간에 목표치 변경 가능)
[ ] dt clamping 적용
→ Math.min(dt, 0.1)
[ ] lag smoothing 구현
→ if (elapsed > 500) startTime += elapsed - 33
[ ] 수렴 조건 threshold 설정
→ position < 0.01, velocity < 0.01
[ ] Ticker 싱글톤 패턴
→ RAF 호출 최소화
[ ] 실기기 테스트
→ 저사양 안드로이드 포함
구현:
이론:
"모바일은 데스크톱이 아니다. 16ms를 기대하지 말고, 500ms도 대응할 수 있게 만들어라."
핵심 3줄:
1. 안정성 > 정확도: Semi-Implicit > RK4
2. 방어 코드는 필수: clamping, smoothing
3. 디테일이 차이를 만든다: threshold, ticker
배운 점:
이 모든 것을 적용해서 ssgoi 2.5.2를 릴리즈했다.
화면 전환 라이브러리로, 부드럽습니다 ㅎ
