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를 동적으로 결정할 수 있습니다.
동작 흐름
spring({frame, fps})가 frame/fps로 시간(초)을 계산
zeta = damping / (2 * sqrt(stiffness * mass))로 감쇠비 계산
zeta < 1이면 underdamped: exp(-ζω₀t) * cos(ωDt) 해 → 바운스 애니메이션
zeta ≥ 1이면 overdamped/critically damped: 바운스 없이 1로 수렴
measureSpring()으로 |1-value| < 0.001이 되는 프레임을 찾아 durationInFrames 결정
장점
- ✓ 실제 물리학: CSS ease-in-out 같은 임의 곡선이 아닌, 물리 법칙에 기반한 자연스러운 모션
- ✓ 결정적(deterministic): 같은 frame에 항상 같은 값, 수학 공식이므로 100% 재현 가능
- ✓ measureSpring()로 duration 자동 계산 → 하드코딩 없이 적절한 길이 결정
단점
- ✗ 수학적 이해 필요: 감쇠비/고유진동수 개념을 모르면 config 튜닝이 시행착오