🧮

Remotion 코드 분석 — spring() 물리 엔진의 내부 구현

감쇠 스프링 미분방정식을 JavaScript로 시뮬레이션하는 spring()의 수학과 코드

GitHub: remotion-dev/remotion/packages/core/src/spring

spring()이 반환하는 부드러운 바운스 애니메이션의 비밀은 감쇠 조화 진동자(damped harmonic oscillator) 방정식입니다.


물리학 배경: 감쇠 스프링

m * x''(t) + c * x'(t) + k * x(t) = 0

m = mass (질량)    → config.mass
c = damping (감쇠) → config.damping  
k = stiffness (강성) → config.stiffness

이 2차 미분방정식의 해는 판별식 D = c² - 4mk에 따라 3가지로 갈립니다:

  • D > 0 (Overdamped): 바운스 없이 천천히 목표에 도달

  • D = 0 (Critically damped): 가장 빠르게 바운스 없이 도달

  • D < 0 (Underdamped): 목표를 지나쳤다 돌아오는 바운스 발생

spring() 핵심 구현

// core/src/spring/index.ts (간략화)
export const spring = ({
  frame,
  fps,
  config = {},
  from = 0,
  to = 1,
  durationInFrames,
}: SpringProps): number => {
  const { mass = 1, damping = 10, stiffness = 100 } = config;

  // 시간을 초 단위로 변환
  const t = frame / fps;

  // 물리 시뮬레이션 실행
  const value = solveSpring(t, mass, damping, stiffness);

  // 0~1 범위를 from~to 범위로 매핑
  return from + value * (to - from);
};

solveSpring() — 미분방정식 풀기

// core/src/spring/solve-spring.ts (간략화)
const solveSpring = (
  t: number,
  mass: number,
  damping: number,
  stiffness: number,
): number => {
  // 판별식 계산
  const omega0 = Math.sqrt(stiffness / mass);  // 고유 진동수
  const zeta = damping / (2 * Math.sqrt(stiffness * mass));  // 감쇠비

  if (zeta < 1) {
    // Underdamped — 바운스 있음
    const omegaD = omega0 * Math.sqrt(1 - zeta * zeta);
    return 1 - Math.exp(-zeta * omega0 * t) *
      (Math.cos(omegaD * t) + (zeta * omega0 / omegaD) * Math.sin(omegaD * t));
  } else if (zeta === 1) {
    // Critically damped — 가장 빠른 수렴
    return 1 - (1 + omega0 * t) * Math.exp(-omega0 * t);
  } else {
    // Overdamped — 느린 수렴, 바운스 없음
    const s1 = -omega0 * (zeta + Math.sqrt(zeta * zeta - 1));
    const s2 = -omega0 * (zeta - Math.sqrt(zeta * zeta - 1));
    return 1 - (s2 * Math.exp(s1 * t) - s1 * Math.exp(s2 * t)) / (s2 - s1);
  }
};

핵심 이해:

  • zeta (ζ): 감쇠비. damping / (2 * sqrt(stiffness * mass))로 계산

    • ζ < 1: 바운스 있음 (대부분의 UI 애니메이션)
    • ζ = 1: 바운스 없이 가장 빠르게 도달
    • ζ > 1: 더 느리게, 바운스 없이 도달
  • omega0 (ω₀): 고유 진동수. sqrt(stiffness / mass). 클수록 빠르게 진동

  • Underdamped 해에서 exp(-ζω₀t)는 감쇠 엔벨로프, cos(ωDt)는 진동 성분

measureSpring() — 스프링 지속 시간 계산

// spring이 목표값(1)에 충분히 가까워지는 프레임을 찾음
export const measureSpring = (config): number => {
  const threshold = 0.001;  // 1 - value < threshold이면 "도착"
  let frame = 0;

  while (true) {
    const value = spring({ frame, fps: 30, config });
    if (Math.abs(1 - value) < threshold && /* 속도도 충분히 작은지 */) {
      return frame;  // 이 프레임에서 스프링 애니메이션 종료
    }
    frame++;
  }
};

이 함수로 spring 애니메이션이 몇 프레임 동안 지속되는지 미리 계산하여, Sequence의 durationInFrames를 동적으로 결정할 수 있습니다.

동작 흐름

1

spring({frame, fps})가 frame/fps로 시간(초)을 계산

2

zeta = damping / (2 * sqrt(stiffness * mass))로 감쇠비 계산

3

zeta < 1이면 underdamped: exp(-ζω₀t) * cos(ωDt) 해 → 바운스 애니메이션

4

zeta ≥ 1이면 overdamped/critically damped: 바운스 없이 1로 수렴

5

measureSpring()으로 |1-value| < 0.001이 되는 프레임을 찾아 durationInFrames 결정

장점

  • 실제 물리학: CSS ease-in-out 같은 임의 곡선이 아닌, 물리 법칙에 기반한 자연스러운 모션
  • 결정적(deterministic): 같은 frame에 항상 같은 값, 수학 공식이므로 100% 재현 가능
  • measureSpring()로 duration 자동 계산 → 하드코딩 없이 적절한 길이 결정

단점

  • 수학적 이해 필요: 감쇠비/고유진동수 개념을 모르면 config 튜닝이 시행착오

사용 사례

spring config 튜닝: mass/damping/stiffness 값이 시각적으로 어떤 효과인지 수학적으로 이해 커스텀 이징 함수 구현: spring()의 패턴을 참고하여 자체 물리 시뮬레이션 제작