베지어 곡선의 이해와 구현
CSS에서는 2차와 3차 베지어 곡선만 지원하지만, n차 베지어 곡선을 직접 구현해보고 싶었다. 자바스크립트와 Canvas를 활용하여 시각화하고, 곡선 생성 과정을 애니메이션으로 보여주는 예제를 만들어보았다.
물론 실제 프로젝트에서 n차 베지어 곡선을 직접 구현할 일은 거의 없다. 하지만 수학적 알고리즘을 직접 코드로 구현하고 시각화하는 과정에서 많은 것을 배울 수 있다. 특히 베지어 곡선의 수학적 원리를 이해하는 데 큰 도움이 된다.
이 글의 목표는 다음과 같다. 첫째, 베지어 곡선의 수학적 공식을 코드로 구현하는 방법을 이해한다. 둘째, 곡선 위의 점을 계산하는 알고리즘을 학습한다. 셋째, 매개변수 t가 변화하면서 곡선이 그려지는 과정을 애니메이션으로 시각화한다.
베지어 곡선의 수학적 기초
베지어 곡선을 이해하기 위해서는 먼저 조합(Combination) 개념이 필요하다. n차 베지어 곡선은 n+1개의 제어점으로 정의되며, 곡선 위의 임의의 점은 조합 공식을 사용하여 계산한다.
조합은 n개의 항목 중 i개를 선택하는 경우의 수를 의미한다. 수학에서는 C(n, i) 또는 binomial coefficient로 표기한다. 베지어 곡선 공식에서 이 조합 계수는 각 제어점의 영향력을 결정하는 가중치로 사용된다.
팩토리얼은 1부터 n까지의 모든 정수를 곱한 값이다. 예를 들어, 5! = 5 × 4 × 3 × 2 × 1 = 120이다. 조합 계산에서 필수적으로 사용되므로 먼저 구현해야 한다.
// 조합 계산 함수
function calculateCombination(n, i) {
return factorial(n) / (factorial(i) * factorial(n - i));
}
// 팩토리얼 계산 함수
function factorial(num) {
if (num < 0) {
return -1;
} else if (num === 0 || num === 1) {
return 1;
} else {
return num * factorial(num - 1);
}
}
다음으로는 곡선 위의 한 점을 계산하는 함수가 필요하다. 이 함수는 모든 제어점의 x좌표와 y좌표를 각각 계산하여 반환한다.
// 베지어 곡선 위의 한 점 계산
function computeBezierPoint(controlPoints, t) {
const x = computeCoordinate(controlPoints, 'x', t);
const y = computeCoordinate(controlPoints, 'y', t);
return { x, y };
}
// 특정 방향(x 또는 y)의 좌표 계산
function computeCoordinate(points, direction, t) {
let result = 0;
const degree = points.length - 1;
for (let i = 0; i < points.length; i++) {
const coefficient = calculateCombination(degree, i);
const position = direction === 'x' ? points[i].x : points[i].y;
const term1 = Math.pow(1 - t, degree - i);
const term2 = Math.pow(t, i);
result += coefficient * position * term1 * term2;
}
return result;
}
베지어 곡선 그리기
이제 위에서 구현한 함수를 활용하여 베지어 곡선을 Canvas에 그려보자. 전체 곡선은 수많은 점들을 연결하여 생성한다. 매개변수 t를 0에서 1까지 나누어 각 위치에서의 점을 계산하면 부드러운 곡선이 된다.
function drawBezierCurve(controlPoints, pointResolution) {
const points = [];
const interval = 1 / pointResolution;
let t = 0;
while (t < 1) {
points.push(computeBezierPoint(controlPoints, t));
t += interval;
}
const canvas = document.getElementById('curveCanvas');
const ctx = canvas.getContext('2d');
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.strokeStyle = '#00bcd4';
ctx.lineWidth = 2;
ctx.stroke();
}
// 사용 예시
const controlPoints = [
{ x: 100, y: 500 },
{ x: 150, y: 400 },
{ x: 600, y: 300 },
{ x: 400, y: 150 }
];
drawBezierCurve(controlPoints, 1000);
위 코드에서 pointResolution 매개변수는 곡선을 구성하는 점의 개수를 결정한다. 값이 클수록 더 부드러운 곡선이 되지만, 그만큼 계산량이 증가한다. 일반적으로 100에서 1000 사이의 값이 적절하다.
애니메이션 구현 원리
베지어 곡선의 생성 과정을 애니메이션으로 보여주기 위해서는 매개변수 t의 변화에 따라 실시간으로 화면을 갱신해야 한다. 핵심 아이디어는 t가 변화함에 따라 제어점들을 연결하는折선들의 위치가 달라지고, 최종적으로 하나의점으로 수렴하는 과정을 시각화하는 것이다.
예를 들어 4차 베지어 곡선의 경우, 5개의 제어점이 있다. t가 0.5일 때, 각 인접한 제어점 쌍 사이에 새로운 중간점을 생성한다. 이 중간점들을 다시 연결하면 4개의점이 되고, 이 과정을 반복하면 최종적으로 곡선 위의 한 점이 구해진다.
이 알고리즘은 재귀적 구조를 가진다. 매 단계마다 점의 개수가 하나씩 줄어들고, n번의 반복 후 곡선 위의 한 점이 결정된다. 애니메이션에서는 이 과정을 한 번에 보여주는 대신, t의 값을 연속적으로 변화시키며 각 단계의折선들을 그린다.
중간점 계산 알고리즘
선형 보간(Linear Interpolation)을 사용하여 두 점 사이의 중간점을 계산한다. t가 0일 때는 시작점에, t가 1일 때는 끝점에 위치한다.
// 선형 보간을 통한 중간점 계산
function computeIntermediatePoint(p1, p2, t) {
return {
x: p1.x + (p2.x - p1.x) * t,
y: p1.y + (p2.y - p1.y) * t
};
}
// 점 배열에서 다음 단계의 점 배열 생성
function computeNextLevelPoints(points, t) {
if (points.length <= 1) {
return points;
}
const nextLevelPoints = [];
for (let i = 0; i < points.length - 1; i++) {
const intermediate = computeIntermediatePoint(points[i], points[i + 1], t);
nextLevelPoints.push(intermediate);
}
return nextLevelPoints;
}
애니메이션 프레임 렌더링
애니메이션의 각 프레임은 다음과 같은 단계로 구성된다. 먼저 현재 t 값에 해당하는 모든 중간점들을 계산하고, 각 단계별로折선과 점을 그린다. 마지막으로 그 때까지 계산된 베지어 곡선의 일부를 그린다.
const animationCanvas = document.getElementById('animationCanvas');
const animationCtx = animationCanvas.getContext('2d');
function clearCanvas() {
animationCanvas.width = animationCanvas.width;
animationCtx.fillStyle = '#2d2d2d';
animationCtx.fillRect(0, 0, animationCanvas.width, animationCanvas.height);
}
function drawPolyline(points, lineColor, showPoints, pointColor) {
if (points.length < 2) {
return points;
}
animationCtx.beginPath();
animationCtx.strokeStyle = lineColor;
animationCtx.lineWidth = 1;
animationCtx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
animationCtx.lineTo(points[i].x, points[i].y);
}
animationCtx.stroke();
if (showPoints) {
points.forEach(point => {
animationCtx.beginPath();
animationCtx.fillStyle = pointColor;
animationCtx.arc(point.x, point.y, 4, 0, Math.PI * 2);
animationCtx.fill();
});
}
return computeNextLevelPoints(points, t);
}
function generateRandomColor() {
const hexChars = '0123456789abcdef';
let color = '#';
for (let i = 0; i < 6; i++) {
color += hexChars[Math.floor(16 * Math.random())];
}
return color;
}
function renderAnimationFrame(controlPoints, t) {
clearCanvas();
let polylinePoints = controlPoints;
let polylineColor = '#ffffff';
const colors = [];
// 각 단계별로 다른 색상 사용
const levelCount = controlPoints.length - 1;
for (let i = 0; i < levelCount; i++) {
colors.push(generateRandomColor());
}
// 첫 번째折선
polylinePoints = drawPolyline(polylinePoints, polylineColor, true, '#ffff00');
// 중간 단계들의折선
let colorIndex = 0;
while (polylinePoints.length > 1) {
polylinePoints = drawPolyline(polylinePoints, colors[colorIndex], true, colors[colorIndex]);
colorIndex++;
}
// 베지어 곡선 그리기
const bezierPoints = [];
const resolution = 100;
const step = 1 / resolution;
let currentT = 0;
while (currentT <= t) {
bezierPoints.push(computeBezierPoint(controlPoints, currentT));
currentT += step;
}
drawPolyline(bezierPoints, '#ff5722', false, null);
}
애니메이션 제어 구현
requestAnimationFrame을 사용하여 부드러운 애니메이션을 구현한다. 재생, 일시정지, 초기화 기능을 추가하여 사용자가 곡선 생성 과정을 상세히 관찰할 수 있게 한다.
class BezierAnimationController {
constructor(canvasId, controlPoints) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.controlPoints = controlPoints;
this.resolution = 100;
this.step = 1 / this.resolution;
this.currentT = 0;
this.animationId = null;
this.isRunning = false;
this.isPaused = false;
}
start() {
if (this.isRunning) return;
this.isRunning = true;
this.isPaused = false;
this.animate();
}
pause() {
this.isPaused = true;
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
resume() {
if (!this.isRunning || !this.isPaused) return;
this.isPaused = false;
this.animate();
}
reset() {
this.pause();
this.currentT = 0;
this.isRunning = false;
clearCanvas();
}
animate() {
if (this.isPaused) return;
if (this.currentT <= 1) {
renderAnimationFrame(this.controlPoints, this.currentT);
this.currentT += this.step;
this.animationId = requestAnimationFrame(() => this.animate());
} else {
renderAnimationFrame(this.controlPoints, 1);
this.isRunning = false;
}
}
}
마우스 인터랙션 추가
사용자가 직접 제어점을 추가하고 위치를 조정할 수 있게 하면 베지어 곡선의 특성을 더욱直观적으로 이해할 수 있다. 마우스 클릭 이벤트를 통해 새로운 제어점을 추가하고, 기존 곡선을 실시간으로 갱신하는 기능을 구현한다.
class InteractiveBezierCanvas {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.controlPoints = [];
this.setupEventListeners();
this.initializeCanvas();
}
initializeCanvas() {
this.ctx.fillStyle = '#333333';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
setupEventListeners() {
this.canvas.addEventListener('click', (event) => {
const point = {
x: event.offsetX,
y: event.offsetY
};
this.controlPoints.push(point);
this.renderControlPoints();
this.drawConnectingLines();
});
}
renderControlPoints() {
this.controlPoints.forEach((point, index) => {
this.ctx.beginPath();
this.ctx.strokeStyle = '#ffffff';
this.ctx.lineWidth = 2;
this.ctx.arc(point.x, point.y, 6, 0, Math.PI * 2);
this.ctx.stroke();
this.ctx.fillStyle = '#ffffff';
this.ctx.font = '12px sans-serif';
this.ctx.fillText(`P${index}`, point.x + 10, point.y - 10);
});
}
drawConnectingLines() {
if (this.controlPoints.length < 2) return;
this.ctx.beginPath();
this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
this.ctx.lineWidth = 1;
this.ctx.setLineDash([5, 5]);
this.ctx.moveTo(this.controlPoints[0].x, this.controlPoints[0].y);
for (let i = 1; i < this.controlPoints.length; i++) {
this.ctx.lineTo(this.controlPoints[i].x, this.controlPoints[i].y);
}
this.ctx.stroke();
this.ctx.setLineDash([]);
}
getControlPoints() {
return this.controlPoints;
}
clear() {
this.controlPoints = [];
this.initializeCanvas();
}
}
완전한 예제 코드
다음은 앞서 설명한 모든 기능을 통합한 완전한 HTML 예제이다. 마우스로 Canvas를 클릭하여 제어점을 추가하고, 애니메이션 버튼을 통해 곡선 생성 과정을 확인할 수 있다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>N차 베지어 곡선 애니메이션</title>
<style>
body {
background-color: #1a1a1a;
color: #ffffff;
font-family: sans-serif;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
canvas {
border: 1px solid #444;
cursor: crosshair;
}
.controls {
margin-top: 15px;
display: flex;
gap: 10px;
}
button {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
border: none;
border-radius: 4px;
transition: background-color 0.3s;
}
.btn-primary {
background-color: #2196f3;
color: white;
}
.btn-secondary {
background-color: #ff5722;
color: white;
}
.btn-tertiary {
background-color: #4caf50;
color: white;
}
button:hover {
opacity: 0.8;
}
</style>
</head>
<body>
<h2>N차 베지어 곡선 시각화</h2>
<canvas id="mainCanvas" width="900" height="550"></canvas>
<div class="controls">
<button id="startBtn" class="btn-primary">애니메이션 시작</button>
<button id="pauseBtn" class="btn-secondary">일시정지</button>
<button id="resetBtn" class="btn-tertiary">초기화</button>
</div>
<script>
// 조합 계산
function binomialCoefficient(n, k) {
if (k < 0 || k > n) return 0;
if (k === 0 || k === n) return 1;
return factorial(n) / (factorial(k) * factorial(n - k));
}
function factorial(num) {
if (num <= 1) return 1;
let result = 1;
for (let i = 2; i <= num; i++) {
result *= i;
}
return result;
}
// 베지어 곡선 점 계산
function evaluateBezierPoint(controlPoints, parameter) {
const degree = controlPoints.length - 1;
let x = 0, y = 0;
for (let i = 0; i < controlPoints.length; i++) {
const bernstein = binomialCoefficient(degree, i) *
Math.pow(1 - parameter, degree - i) *
Math.pow(parameter, i);
x += controlPoints[i].x * bernstein;
y += controlPoints[i].y * bernstein;
}
return { x, y };
}
// 중간점 계산
function computeMidpoint(p1, p2, t) {
return {
x: p1.x + (p2.x - p1.x) * t,
y: p1.y + (p2.y - p1.y) * t
};
}
// 다음 레벨 점 배열 생성
function computeNextLevel(currentPoints, t) {
if (currentPoints.length <= 1) return currentPoints;
const nextPoints = [];
for (let i = 0; i < currentPoints.length - 1; i++) {
nextPoints.push(computeMidpoint(currentPoints[i], currentPoints[i + 1], t));
}
return nextPoints;
}
// Canvas 설정
const canvas = document.getElementById('mainCanvas');
const ctx = canvas.getContext('2d');
let controlPoints = [];
let animationProgress = 0;
const pointResolution = 100;
const progressStep = 1 / pointResolution;
let animationFrameId = null;
let isAnimating = false;
let isPaused = false;
function initializeDisplay() {
ctx.fillStyle = '#2d2d2d';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// 점 그리기
function drawPoint(point, color, radius = 5) {
ctx.beginPath();
ctx.fillStyle = color;
ctx.arc(point.x, point.y, radius, 0, Math.PI * 2);
ctx.fill();
}
// 선 그리기
function drawLine(p1, p2, color, dashed = false) {
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
if (dashed) ctx.setLineDash([4, 4]);
else ctx.setLineDash([]);
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
ctx.setLineDash([]);
}
//,折선 그리기
function drawPolyline(points, color, showPoints, pointColor) {
if (points.length < 2) return points;
for (let i = 0; i < points.length - 1; i++) {
drawLine(points[i], points[i + 1], color);
}
if (showPoints) {
points.forEach(p => drawPoint(p, pointColor || color));
}
return computeNextLevel(points, animationProgress);
}
// 무작위 색상 생성
function randomColor() {
const letters = '0123456789abcdef';
let color = '#';
for (let i = 0; i < 6; i++) {
color += letters[Math.floor(16 * Math.random())];
}
return color;
}
// 애니메이션 프레임 렌더링
function renderFrame() {
initializeDisplay();
// 제어점과 연결선 그리기
controlPoints.forEach(p => drawPoint(p, '#ffffff', 6));
for (let i = 0; i < controlPoints.length - 1; i++) {
drawLine(controlPoints[i], controlPoints[i + 1], 'rgba(255,255,255,0.3)', true);
}
let currentLevelPoints = [...controlPoints];
const colors = [];
const levelCount = controlPoints.length - 1;
for (let i = 0; i < levelCount; i++) {
colors.push(randomColor());
}
// 첫 번째折선
currentLevelPoints = drawPolyline(currentLevelPoints, '#ffffff', true, '#ffff00');
// 중간 레벨折선들
let colorIdx = 0;
while (currentLevelPoints.length > 1) {
currentLevelPoints = drawPolyline(
currentLevelPoints,
colors[colorIdx],
true,
colors[colorIdx]
);
colorIdx++;
}
// 베지어 곡선 그리기
const bezierPoints = [];
let t = 0;
while (t <= animationProgress) {
bezierPoints.push(evaluateBezierPoint(controlPoints, t));
t += progressStep;
}
if (bezierPoints.length > 1) {
ctx.beginPath();
ctx.strokeStyle = '#e91e63';
ctx.lineWidth = 2.5;
ctx.moveTo(bezierPoints[0].x, bezierPoints[0].y);
for (let i = 1; i < bezierPoints.length; i++) {
ctx.lineTo(bezierPoints[i].x, bezierPoints[i].y);
}
ctx.stroke();
}
}
// 애니메이션 루프
function startAnimation() {
if (isAnimating && !isPaused) return;
if (isPaused) {
isPaused = false;
isAnimating = true;
animationLoop();
return;
}
isAnimating = true;
isPaused = false;
animationProgress = 0;
animationLoop();
}
function animationLoop() {
if (!isAnimating || isPaused) return;
if (animationProgress <= 1) {
renderFrame();
animationProgress += progressStep;
animationFrameId = requestAnimationFrame(animationLoop);
} else {
renderFrame();
animationProgress = 1;
isAnimating = false;
}
}
function pauseAnimation() {
isPaused = true;
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
}
function resetCanvas() {
pauseAnimation();
isAnimating = false;
animationProgress = 0;
controlPoints = [];
initializeDisplay();
}
// 이벤트 리스너 등록
canvas.addEventListener('click', (event) => {
const newPoint = {
x: event.offsetX,
y: event.offsetY
};
controlPoints.push(newPoint);
if (!isAnimating) {
renderFrame();
}
});
document.getElementById('startBtn').addEventListener('click', startAnimation);
document.getElementById('pauseBtn').addEventListener('click', pauseAnimation);
document.getElementById('resetBtn').addEventListener('click', resetCanvas);
// 초기화
initializeDisplay();
</script>
</body>
</html>
사용 방법 및 확장 아이디어
위 예제를 실행하면 마우스로 Canvas 위의 아무 곳이나 클릭하여 제어점을 추가할 수 있다. 3개 이상의 점을 추가한 후 애니메이션 시작 버튼을 누르면 베지어 곡선이 점진적으로 그려지는 과정을 확인할 수 있다.
애니메이션 일시정지 버튼을 사용하면 특정 순간에서 재생이 멈추며, 이 기능을 활용하여 각 프레임의折선 구조를 자세히 관찰할 수 있다. 초기화 버튼을 누르면 모든 제어점이清除되고 새로운 곡선부터 시작할 수 있다.
이 구현체를 바탕으로 다양한 확장이 가능하다. 제어점을 드래그하여 이동하는 기능을 추가하면 실시간으로 곡선의 변화를 관찰할 수 있다. 또한 곡선의 두께나 색상을 조절하는 UI를 추가하면 더욱丰富多彩한 시각화 효과를 낼 수 있다. 더 나아가 3D 공간에서 베지어 곡선을 구현하면 입체적인 곡면 렌더링도 가능할 것이다.