모션 수학과 timing 함수
부록으로 모션을 봅시다.
지금까지는 주로 “어디에 그릴 것인가?”를 다뤘습니다. 좌표, 행렬, viewport, selection, vector network까지 전부 공간의 문제였죠. 모션은 여기에 시간축을 하나 더 붙입니다.
geometry = position, size, rotation, color
motion = geometry가 시간에 따라 어떻게 바뀌는가
그래픽 에디터에서도 모션은 중요합니다. selection handle이 부드럽게 나타나고, canvas zoom이 튀지 않고, drag 후 snap guide가 적당히 사라지고, prototype transition을 미리 보여주려면 결국 timing을 다뤄야 합니다.
이제 칠판에 시간까지 올라왔습니다. 칠판이 점점 좁아지네요.
1. 모션은 시간을 progress로 바꾸는 일이다
가장 기본 모델은 이겁니다.
elapsed = now - startTime
progress = clamp(elapsed / duration, 0, 1)
progress는 항상 0..1 범위로 정규화합니다.
0 = animation start
1 = animation end
이제 시작 값과 끝 값을 보간합니다.
value = lerp(from, to, progress)
선형 보간은 우리가 gradient에서 본 공식과 같습니다.
lerp(a, b, t) = a + (b - a) * t
색도, 위치도, scale도, opacity도 같은 구조로 갈 수 있습니다.
x = lerp(x0, x1, p)
opacity = lerp(0, 1, p)
scale = lerp(0.96, 1, p)
그러니까 모션은 새로운 괴물이 아닙니다. 기존 수학에 시간을 하나 끼워 넣은 겁니다. 물론 괴물은 아니어도 가끔 성격은 있습니다.
2. timing 함수는 progress를 다시 매핑한다
linear animation은 속도가 일정합니다.
eased = progress
하지만 실제 UI에서는 linear가 딱딱하게 느껴질 때가 많습니다. 그래서 timing 함수를 씁니다.
eased = easing(progress)
value = lerp(from, to, eased)
즉 timing 함수는 값 자체를 바꾸는 함수가 아니라, 시간의 진행률을 다시 구부리는 함수입니다.
linear:
f(p) = p
easeInQuad:
f(p) = p^2
easeOutQuad:
f(p) = 1 - (1 - p)^2
easeInOutCubic:
if p < 0.5:
f(p) = 4p^3
else:
f(p) = 1 - (-2p + 2)^3 / 2
같은 시작점과 끝점이어도 timing 함수가 달라지면 속도감이 달라집니다.
position(t) = lerp(start, end, easing(t))
velocity(t) = derivative(position(t))
사용자는 보통 위치를 보는 것 같지만, 사실 속도의 변화를 많이 느낍니다. UI 모션에서 “부드럽다”는 말은 대체로 속도와 가속도가 갑자기 튀지 않는다는 뜻입니다.
3. CSS cubic-bezier는 시간-진행률 곡선이다
CSS transition에서 자주 보는 값입니다.
transition-timing-function: cubic-bezier(0.25, 0.1, 0.25, 1);
이건 cubic bezier curve입니다.
P0 = (0, 0)
P1 = (x1, y1)
P2 = (x2, y2)
P3 = (1, 1)
주의할 점이 있습니다. CSS cubic-bezier에서 x축은 시간이고 y축은 progress입니다.
x = time progress
y = eased progress
그래서 p가 들어오면 바로 bezier의 y를 읽는 게 아닙니다. 먼저 x(t) = p가 되는 curve parameter t를 찾고, 그 t에서 y(t)를 읽습니다.
given p:
find u where bezierX(u) = p
eased = bezierY(u)
브라우저가 이걸 대신 해줍니다. 우리가 스크립트 엔진을 만들면 이 계산을 직접 하거나, 충분히 근사해야 합니다.
4. spring은 목표값을 향해 감쇠하는 시스템이다
UI 모션에는 spring도 자주 씁니다.
간단한 감쇠 spring 느낌은 이렇게 근사할 수 있습니다.
f(p) = 1 - exp(-damping * p) * cos(frequency * p)
이 함수는 1을 향해 가면서 살짝 넘쳤다가 돌아옵니다.
overshoot -> settle
물리 spring을 조금 더 제대로 만들면 위치와 속도를 상태로 둡니다.
force = -stiffness * (x - target)
dampingForce = -damping * velocity
acceleration = force + dampingForce
velocity += acceleration * dt
x += velocity * dt
이건 시간 적분입니다. 여기서 dt는 frame 사이 시간입니다.
dt = (now - previousFrameTime) / 1000
CSS transition은 선언형이라 편하고, spring engine은 직접 상태를 다루니 더 유연합니다. 에디터의 pan inertia, snap animation, object settle 같은 건 spring 모델이 잘 어울립니다.
5. requestAnimationFrame으로 작은 엔진 만들기
스크립트 모션 엔진의 기본 루프는 이렇게 생겼습니다.
function tick(now) {
const elapsed = now - startTime;
const progress = clamp(elapsed / duration, 0, 1);
const eased = easing(progress);
const x = lerp(fromX, toX, eased);
render(x);
if (progress < 1) {
requestAnimationFrame(tick);
}
}
핵심은 render와 model update를 분리하는 겁니다.
time -> progress -> eased progress -> model value -> render
에디터에서는 더 중요합니다. DOM의 현재 위치를 읽어서 다음 위치를 만들면 금방 꼬입니다. model이 진짜 상태이고, DOM은 그 상태를 보여주는 projection이어야 합니다. 우리 강의 처음부터 계속 나온 말이죠. 교수님도 이제 거의 자동응답기입니다.
6. 엔진을 조금 더 제대로 쪼개기
단일 값 하나만 움직이면 엔진이랄 것도 없습니다. 실제 엔진은 보통 네 덩어리로 나눕니다.
Ticker
requestAnimationFrame을 관리한다.
Player
play, pause, cancel, finish 상태를 가진다.
Timeline
여러 track의 delay, duration, easing을 관리한다.
Interpolator
number, vector, color, transform 같은 값을 보간한다.
간단한 자료구조는 이렇게 잡을 수 있습니다.
type Track<T> = {
property: string;
from: T;
to: T;
delay: number;
duration: number;
easing: (p: number) => number;
interpolate: (from: T, to: T, t: number) => T;
};
type Animation = {
startTime: number;
duration: number;
tracks: Track<unknown>[];
state: "idle" | "running" | "paused" | "finished";
};
매 frame마다 track별 progress를 따로 계산합니다.
globalProgress = clamp((now - startTime) / animationDuration, 0, 1)
trackProgress =
clamp((elapsed - track.delay) / track.duration, 0, 1)
trackValue =
interpolate(track.from, track.to, track.easing(trackProgress))
이렇게 하면 하나의 animation 안에서도 property마다 다르게 움직일 수 있습니다.
x : delay 0ms, duration 700ms
opacity : delay 0ms, duration 220ms
scale : delay 120ms, duration 360ms
rotate : delay 180ms, duration 600ms
color : delay 300ms, duration 400ms
데모의 Mode: timeline이 바로 이 모델입니다. 하나의 전체 progress가 있고, 그 안에서 x, scale, rotate, opacity, color가 각자 local progress를 가집니다.
global p = 0.50
x track:
local p = 0.61
scale track:
local p = 0.79
color track:
local p = 0.28
여기서 중요한 건 progress를 한 번만 쓰지 않는다는 점입니다. timeline 엔진은 전체 시간과 track 시간을 분리합니다. 선생님이 출석을 한 번 부르지만 학생들은 각자 다른 속도로 움직이는 그런 느낌입니다. 반 전체가 산만합니다.
7. 값 타입마다 보간 방식이 다르다
number는 쉽습니다.
number = lerp(a, b, t)
2D point도 쉽습니다.
x = lerp(ax, bx, t)
y = lerp(ay, by, t)
색은 채널별 보간입니다.
r = lerp(r0, r1, t)
g = lerp(g0, g1, t)
b = lerp(b0, b1, t)
a = lerp(a0, a1, t)
transform은 살짝 조심해야 합니다. matrix 여섯 숫자를 그냥 보간하면 예상과 다른 shear나 scale이 끼어 보일 수 있습니다.
나쁜 경우:
matrixA와 matrixB의 a,b,c,d,e,f를 그대로 lerp
더 나은 경우:
translate, rotate, scale, skew로 분해
각 성분을 보간
다시 matrix로 합성
2D editor에서는 보통 이렇게 다룹니다.
position: vector lerp
rotation: shortest-angle lerp
scale: positive number lerp
opacity: clamp(lerp)
color: color space policy를 정해서 lerp
회전은 특히 350deg -> 10deg가 문제입니다.
naive delta = 10 - 350 = -340deg
shortest delta = +20deg
그래서 각도 보간은 shortest path를 씁니다.
delta = ((to - from + 180) mod 360) - 180
angle = from + delta * t
작은 차이 같지만, UI가 갑자기 반 바퀴 돌아가면 사용자는 “왜 애가 갑자기 체조를 하지?” 하게 됩니다.
8. interrupt와 cancel이 엔진의 진짜 실전이다
모션 엔진은 재생보다 중단이 더 어렵습니다.
play는 쉽다.
interrupt가 실전이다.
에디터에서는 사용자가 애니메이션 도중에 바로 다시 조작합니다.
snap animation 중 다시 drag
zoom animation 중 wheel 입력
selection fade 중 다른 node 선택
prototype transition 중 back action
이때 정책이 필요합니다.
cancel:
현재 animation을 즉시 중단하고 새 입력을 적용한다.
retarget:
현재 값과 속도를 새 animation의 시작값으로 삼는다.
finish:
현재 animation을 끝 상태로 보낸 뒤 다음 animation을 시작한다.
에디터 조작에서는 대체로 cancel 또는 retarget이 좋습니다. 사용자의 직접 조작이 항상 우선입니다.
function interrupt(animation, nextTarget) {
const current = animation.sample(performance.now());
animation.cancel();
return animate({
from: current,
to: nextTarget,
preserveVelocity: true
});
}
spring 엔진에서는 preserveVelocity가 중요합니다. 속도를 0으로 리셋하면 움직임이 툭 끊깁니다.
9. CSS 모션으로 어디까지 할 수 있나?
CSS 모션은 생각보다 멀리 갑니다. MDN 기준으로 CSS transitions는 property 변경을 duration, delay, easing으로 보간할 수 있고, CSS animations는 @keyframes로 여러 구간을 선언할 수 있습니다. Web Animations API의 Element.animate()는 브라우저 animation engine을 JS에서 직접 다루는 방법입니다.
CSS만으로 잘 되는 영역은 이렇습니다.
hover/focus feedback
button press
tooltip/popover fade
panel slide
selection outline fade
toast enter/exit
simple loading indicator
state A -> state B transition
scroll-driven visual progress
주로 좋은 property는 이쪽입니다.
transform
opacity
filter
color/background-color
box-shadow
clip-path 일부
registered custom property
특히 transform과 opacity는 layout을 다시 풀지 않아도 되는 경우가 많아서 UI 모션의 기본 재료로 좋습니다.
.node {
transition:
transform 180ms cubic-bezier(0.2, 0, 0, 1),
opacity 120ms linear;
}
.node[data-selected="true"] {
transform: translateY(-2px) scale(1.02);
opacity: 1;
}
@keyframes는 반복되거나 중간 상태가 있는 모션에 좋습니다.
@keyframes pulse {
0% { transform: scale(1); opacity: 0.72; }
50% { transform: scale(1.08); opacity: 1; }
100% { transform: scale(1); opacity: 0.72; }
}
.cursor-ring {
animation: pulse 900ms ease-in-out infinite;
}
registered custom property를 쓰면 CSS 변수도 타입을 갖고 보간될 수 있습니다.
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.dial {
transition: --angle 240ms ease;
transform: rotate(var(--angle));
}
이건 gradient, mask, transform helper 값을 CSS 변수로 움직일 때 유용합니다. 그냥 --angle만 쓰면 브라우저가 중간값 타입을 모를 수 있는데, @property로 등록하면 보간 가능한 값이 됩니다.
Scroll-driven animation도 CSS 영역입니다. 스크롤 위치를 timeline으로 삼아 animation progress를 만들 수 있습니다.
scroll position -> animation progress
문서형 강의 페이지, progress indicator, section reveal에는 좋습니다. 다만 그래픽 에디터의 camera나 selection model처럼 앱 상태와 강하게 묶인 모션은 JS 쪽이 더 명확합니다.
10. CSS 모션의 한계
CSS가 모든 모션을 해결하지는 않습니다.
어려운 영역은 이렇습니다.
drag 중 포인터를 정확히 따라가는 motion
physics spring with velocity
camera inertia
multi-object choreography
timeline scrubber
undo/redo와 연결된 model animation
canvas/WebGL object animation
collision, constraints, snapping과 결합된 motion
CSS는 선언형이라 “상태가 바뀌면 브라우저가 알아서 보간”하는 데 강합니다. 반대로 매 frame 모델 값을 계산하고, 사용자의 입력으로 언제든 중단하고, 여러 오브젝트의 시간을 맞춰야 하면 JS 엔진이 필요합니다.
또 layout property는 조심해야 합니다.
width, height, top, left, margin
이런 값도 애니메이션이 가능할 수 있지만 layout을 다시 계산하게 만들 수 있습니다. 에디터 canvas 위 오브젝트라면 가능하면 transform으로 움직이는 편이 낫습니다.
권장:
transform: translate(...) scale(...) rotate(...)
주의:
left/top/width/height를 매 frame 변경
그리고 auto는 보간하기 까다롭습니다.
height: 0 -> height: auto
이런 모션은 CSS만으로 깔끔하지 않을 때가 많습니다. content height를 측정해서 px로 넣거나, grid trick, clip, transform scale 같은 우회가 필요합니다.
11. CSS, WAAPI, JS 엔진 선택 기준
정리하면 이렇게 나눌 수 있습니다.
| 방식 | 좋은 곳 | 약한 곳 |
|---|---|---|
| CSS transition | 상태 A/B 전환, hover/focus, 단순 UI feedback | 세밀한 중단/재타겟, 물리 기반 motion |
| CSS keyframes | 반복, 로딩, 정해진 시퀀스 | 앱 model과 동기화되는 timeline |
| CSS scroll-driven animation | 문서/스크롤 기반 progress | editor camera 같은 내부 상태 기반 progress |
WAAPI Element.animate() | JS에서 브라우저 animation player 제어 | 복잡한 물리/geometry engine |
| custom JS engine | editor model, physics, multi-object timeline | 직접 최적화와 lifecycle 관리 필요 |
실무적으로는 이렇게 잡으면 좋습니다.
DOM UI chrome:
CSS transition / keyframes
단일 element animation을 JS에서 제어:
Web Animations API
editor canvas model:
custom JS motion engine
Canvas/WebGL renderer:
render loop 안에서 직접 time integration
12. editor motion에서 조심할 것
에디터 모션은 예쁘기만 하면 안 됩니다. 조작감이 먼저입니다.
drag 중:
사용자 입력이 우선이다.
느린 easing으로 포인터를 따라오면 안 된다.
drag 끝:
snap, inertia, settle animation을 적용할 수 있다.
zoom:
cursor anchored zoom 공식을 깨면 안 된다.
selection:
overlay는 geometry model을 늦게 따라오면 안 된다.
즉 모션은 정보 구조를 흐리면 안 됩니다. 특히 Figma 같은 도구에서는 사용자가 “내가 잡은 점이 어디 있는지”를 즉시 믿을 수 있어야 합니다.
좋은 기준은 이렇습니다.
direct manipulation -> 즉시 반응
state transition -> 짧고 명확한 easing
decorative feedback -> interrupt 가능
camera motion -> 좌표 보존 공식 우선
마무리
모션 수학의 핵심은 간단합니다.
1. 시간을 progress로 정규화한다.
2. timing 함수로 progress를 다시 매핑한다.
3. 보간으로 값을 만든다.
4. requestAnimationFrame에서 매 frame 렌더링한다.
이 구조를 알면 CSS transition도, cubic-bezier도, spring도 같은 가족으로 보입니다.
그래픽 에디터에서 모션은 장식이 아닙니다. 사용자가 geometry 변화를 이해하도록 돕는 시간축의 시각화입니다. 예쁘게 움직이는 것도 좋지만, 먼저 정확히 움직여야 합니다. 예쁜데 틀리면 그건 그냥 자신감 있는 버그입니다.