Bezier curve 길이 구하기
이제 정말 마지막으로, motion path의 속살을 살짝 열어봅시다.
앞 강의에서는 getTotalLength()와 getPointAtLength()를 사용했습니다. 브라우저가 path 길이를 알려주고, 특정 거리의 점도 알려줬죠. 아주 친절합니다. 그런데 디자인 에디터를 만들다 보면 언젠가는 이런 질문이 옵니다.
Bezier 곡선의 길이는 대체 어떻게 구하지?
좋습니다. 마지막 수학 간식입니다. 딱딱해 보이지만 핵심은 간단합니다. 곡선을 아주 작은 직선 조각으로 쪼개고, 그 조각들의 길이를 더합니다.
1. t는 길이가 아니다
cubic Bezier는 네 점으로 정의합니다.
P0 = 시작점
P1 = 시작 handle
P2 = 끝 handle
P3 = 끝점
점은 B(t)로 얻습니다.
B(t) = (1-t)^3 P0
+ 3(1-t)^2t P1
+ 3(1-t)t^2 P2
+ t^3 P3
여기서 t는 0..1입니다. 그래서 t = 0.5를 보면 “중간 지점이겠네?”라고 생각하기 쉽습니다.
맞습니다. parameter의 중간입니다.
하지만 보통은 길이의 중간이 아닙니다.
t가 같은 간격으로 증가해도
곡선 위에서 이동한 거리는 구간마다 달라질 수 있다.
왜냐하면 Bezier는 control point에 끌려 다니는 곡선이기 때문입니다. 어느 구간은 느슨하게 지나가고, 어느 구간은 확 휘어갑니다. 그래서 motion path에서 일정한 속도를 만들려면 t가 아니라 arc length, 즉 곡선을 따라간 거리를 기준으로 계산해야 합니다.
2. 진짜 공식은 적분이다
곡선 길이의 수학적 정의는 이렇습니다.
length = ∫₀¹ |B'(t)| dt
여기서 B'(t)는 tangent, 조금 더 물리적으로 말하면 t가 변할 때 점이 움직이는 속도 벡터입니다.
B'(t) = 3(1-t)^2(P1 - P0)
+ 6(1-t)t(P2 - P1)
+ 3t^2(P3 - P2)
그리고 |B'(t)|는 그 벡터의 길이입니다.
|v| = sqrt(v.x² + v.y²)
말은 멋있습니다.
곡선 길이 = 아주 작은 tangent 이동량을 0부터 1까지 전부 더한 것
그런데 cubic Bezier의 길이는 일반적으로 간단한 닫힌 공식으로 딱 떨어지지 않습니다. 그래서 실무에서는 보통 근사합니다. 수학이 도망간 자리에 엔지니어링이 들어옵니다.
3. 가장 쉬운 방법: polyline sampling
가장 직관적인 방법은 t를 일정 간격으로 나눠 점을 찍는 것입니다.
B(0.00), B(0.05), B(0.10), ... B(1.00)
그리고 이 점들을 직선으로 연결합니다.
approxLength =
distance(B(0.00), B(0.05))
+ distance(B(0.05), B(0.10))
+ ...
+ distance(B(0.95), B(1.00))
샘플 수가 적으면 빠르지만 삐뚤삐뚤한 근사입니다. 샘플 수가 많으면 정확해지지만 계산량이 늘어납니다.
samples 8 -> 빠름, 오차 큼
samples 32 -> 대체로 충분
samples 128 -> 더 정확, 더 무거움
편집기에서는 상황에 따라 다르게 씁니다.
드래그 중 preview -> 적당한 sample
export / final layout -> 더 높은 sample
매 프레임 반복 계산 -> cache 또는 lookup table
4. lookup table 만들기
길이만 알고 끝이면 sampling으로 충분합니다. 그런데 motion path에서는 보통 이런 질문이 필요합니다.
전체 길이의 37% 지점은 어디인가?
이건 distance -> point 문제입니다. Bezier 함수는 원래 t -> point이므로 중간에 변환표가 필요합니다.
[
{ t: 0.00, length: 0 },
{ t: 0.05, length: 12.4 },
{ t: 0.10, length: 25.9 },
...
{ t: 1.00, length: 318.7 }
]
이 표를 누적 길이 lookup table이라고 부릅니다.
원하는 거리가 120px이면 table에서 그 거리가 들어갈 구간을 찾습니다.
table[i].length <= 120 <= table[i + 1].length
그리고 그 구간 안에서 다시 보간합니다.
ratio = (targetLength - L0) / (L1 - L0)
t = mix(t0, t1, ratio)
point = B(t)
이렇게 하면 distance -> t -> point가 됩니다. motion path, text on path, connector marker, dash 위치 계산이 전부 이 친구를 좋아합니다. 성실한 표 하나가 모두를 먹여 살립니다.
5. uniform t와 uniform length는 다르다
데모에서 파란 점은 t를 같은 간격으로 찍은 점입니다.
t = 0.0, 0.1, 0.2, ... 1.0
노란 점은 길이를 같은 간격으로 나눈 점입니다.
distance = 0%, 10%, 20%, ... 100%
곡선이 많이 휘거나 control point가 한쪽으로 몰려 있으면 두 점열의 간격이 다르게 보입니다. 이 차이가 바로 motion path에서 속도가 튀는 이유입니다.
uniform t -> 계산은 쉽지만 속도가 일정하지 않을 수 있음
uniform length -> 계산은 번거롭지만 움직임이 일정해짐
그래서 path animation에서 “일정한 속도”가 필요하면 uniform length 쪽으로 가야 합니다.
6. adaptive subdivision은 더 똑똑한 쪼개기다
sample count를 무작정 올리는 대신, 곡선이 많이 휘는 구간만 더 잘게 쪼갤 수도 있습니다.
아이디어는 이렇습니다.
control polygon length = |P0-P1| + |P1-P2| + |P2-P3|
chord length = |P0-P3|
if controlPolygonLength - chordLength is small:
거의 직선이므로 chord로 근사
else:
Bezier를 반으로 나누고 양쪽을 다시 검사
이 방식은 평평한 구간에서는 계산을 아끼고, 많이 굽은 구간에서는 정확도를 올립니다. path가 복잡한 디자인 에디터에서는 꽤 실용적입니다.
다만 강의 데모는 원리를 눈으로 보기 위해 fixed sampling을 사용합니다. 먼저 단순한 방법을 정확히 이해하고, 나중에 adaptive로 업그레이드하면 됩니다.
7. 여러 path segment가 이어져 있으면?
실제 SVG path나 Figma vector는 cubic Bezier 하나로 끝나지 않는 경우가 많습니다.
M 40 200
C 90 40, 180 40, 230 170
L 320 170
Q 380 170, 420 90
C 480 20, 560 140, 520 230
이런 path는 여러 segment의 목록으로 보면 됩니다.
segment 0 = cubic Bezier
segment 1 = line
segment 2 = quadratic Bezier
segment 3 = cubic Bezier
전체 길이는 각 segment 길이의 합입니다.
totalLength =
length(segment 0)
+ length(segment 1)
+ length(segment 2)
+ length(segment 3)
직선 segment는 바로 구합니다.
lineLength = distance(P0, P1)
quadratic/cubic Bezier segment는 앞에서 본 것처럼 sampling하거나 adaptive subdivision으로 근사합니다.
bezierLength ≈ sum(distance(B(tᵢ), B(tᵢ₊₁)))
그리고 전체 path용 table을 하나 더 만듭니다.
pathTable = [
{ segmentIndex: 0, startLength: 0, endLength: 142 },
{ segmentIndex: 1, startLength: 142, endLength: 232 },
{ segmentIndex: 2, startLength: 232, endLength: 318 },
{ segmentIndex: 3, startLength: 318, endLength: 491 }
]
이제 distance -> point는 두 단계입니다.
1. targetDistance가 어느 segment 안에 있는지 찾는다.
2. segment 안의 localDistance로 바꾼다.
3. 그 segment의 table에서 localDistance -> localT를 찾는다.
4. segment.pointAt(localT)를 계산한다.
예를 들어 전체 path에서 targetDistance = 260px이라면:
260은 segment 2의 232..318 구간에 있다.
localDistance = 260 - 232 = 28
localT = segment2Table에서 28px에 해당하는 t
point = segment2.pointAt(localT)
코드 구조는 보통 이렇게 잡습니다.
type SegmentLengthTable = {
kind: "line" | "quadratic" | "cubic";
startLength: number;
endLength: number;
samples: Array<{ t: number; localLength: number; point: Point }>;
};
그리고 sampling 함수는 segment 종류별로 갈라집니다.
line.pointAt(t) = mix(P0, P1, t)
quadratic.pointAt(t) = quadraticBezier(P0, P1, P2, t)
cubic.pointAt(t) = cubicBezier(P0, P1, P2, P3, t)
여기서 중요한 건 t도 segment마다 local 값이라는 점입니다.
전체 path progress = 0.57
전체 path distance = totalLength * 0.57
선택된 segment = 2
segment localT = 0.34
즉 전체 path의 progress와 각 segment의 t는 같은 값이 아닙니다. 이걸 헷갈리면 motion path marker가 어떤 구간에서는 빠르게 튀고, 어떤 구간에서는 느릿느릿 기어갑니다. 에디터가 갑자기 성격을 갖기 시작하는 순간입니다.
연결점도 봐야 합니다.
C0 continuity: 이전 segment 끝점과 다음 segment 시작점이 같다.
C1 continuity: 연결점에서 tangent 방향이 같다.
C2 continuity: tangent 변화까지 부드럽다.
길이 계산만 놓고 보면 C0만 되어도 segment를 이어 붙일 수 있습니다. 하지만 offset-rotate:auto, text on path, marker 방향까지 자연스럽게 만들려면 연결점의 tangent가 중요합니다. 연결점에서 tangent가 확 꺾이면 오브젝트도 확 돕니다. 그것도 의도라면 괜찮고, 부드러운 path라면 handle을 맞춰 C1에 가깝게 만들어야 합니다.
M 명령으로 끊어진 subpath는 별도로 취급하는 편이 안전합니다.
M 0 0 C ...
M 300 0 C ...
이건 눈에는 같은 SVG path 하나처럼 보여도, 실제로는 펜을 들어서 다른 곳에 다시 찍은 것입니다. motion path로 쓸 때는 두 subpath 사이를 순간이동하게 둘지, gap을 무시하고 따로 path로 다룰지 정책을 정해야 합니다.
닫힌 path도 하나 더 조심합니다.
Z = close path
Z는 현재점에서 subpath 시작점까지 선분을 하나 추가하는 것처럼 생각하면 됩니다. 따라서 closed shape의 전체 길이에는 마지막 닫는 선분도 들어갑니다.
정리하면 여러 segment path는 이렇게 처리합니다.
1. SVG/Figma path를 segment 목록으로 파싱한다.
2. segment마다 local length table을 만든다.
3. segment들의 startLength/endLength로 global table을 만든다.
4. global distance로 segment를 찾는다.
5. segment localDistance를 localT로 바꾼다.
6. segment.pointAt(localT), segment.tangentAt(localT)를 구한다.
단일 Bezier 길이 계산은 작은 부품이고, 여러 segment path는 그 부품들을 누적 길이 좌표계에 올려놓은 것입니다. 자, 여기서 또 누적합이 등장합니다. 수학이 은근 같은 농담을 반복하죠.
8. 에디터에서는 어디에 쓰이나?
Bezier 길이 계산은 생각보다 자주 등장합니다.
motion path의 일정 속도 이동
text on path에서 글자 위치 배치
connector 끝 marker를 path 끝에서 일정 거리만큼 안쪽에 놓기
path 위 snapping point 만들기
stroke dash/gap을 path 길이에 맞춰 분배하기
path 편집 중 live length 표시
CSS만 쓸 때는 브라우저 API에 맡겨도 됩니다.
const length = path.getTotalLength();
const point = path.getPointAtLength(distance);
하지만 editor model을 직접 들고 있다면 이야기가 달라집니다. DOM에 path를 만들기 전에 계산해야 할 수도 있고, Canvas/WebGL에서 같은 geometry를 써야 할 수도 있습니다. 그때는 직접 Bezier -> sampled polyline -> length table을 만드는 편이 좋습니다.
마무리
Bezier 길이 계산은 겁먹을 필요가 없습니다.
1. B(t)로 점을 찍는다.
2. 이웃한 점 사이의 거리를 더한다.
3. 누적 길이 table을 만든다.
4. 여러 segment라면 segment별 table과 전체 path table을 함께 둔다.
5. distance를 table에서 찾아 t로 되돌린다.
6. B(t) 또는 segment.pointAt(t)로 최종 point를 얻는다.
마지막 결론은 아주 소박합니다.
곡선도 결국 충분히 잘게 보면 직선들의 합이다.
그래픽 수학은 가끔 멋진 척을 하지만, 손에 쥐고 보면 결국 점, 벡터, 거리, 보간입니다. 여기까지 왔다면 CSS 기반 디자인 에디터의 geometry 기초는 꽤 든든하게 잡힌 겁니다.