在前端開發中,貝賽爾曲線無處不在:
- 它可以用來繪製曲線,在svg和canvas中,原生提供的曲線繪製都是使用貝賽爾曲線
- 它也可以用來描述一個緩動演算法,設定css的
transition-timing-function
屬性,可以使用貝塞爾曲線來描述過渡的緩動計算 - 幾乎所有前端2D或3D圖形圖表庫(echarts,d3,three.js)都會使用到貝塞爾曲線
這篇文章我準備從實現一個非常簡單的曲線動畫效果入手,幫助大家徹底地弄懂什麼是貝塞爾曲線,以及它有哪些特性,文章中有一點點數學公式,但是都非常簡單:)。
可以點選這裡檢視線上演示
在寫程式碼之前,先了解一下什麼是貝塞爾曲線吧。
貝塞爾曲線
貝塞爾曲線(Bezier curve)是計算機圖形學中相當重要的引數曲線,它通過一個方程來描述一條曲線,根據方程的最高階數,又分為線性貝賽爾曲線,二次貝塞爾曲線、三次貝塞爾曲線和更高階的貝塞爾曲線。
下面詳細介紹一下用得比較多的二次貝塞爾曲線和三次貝塞爾曲線
二次貝塞爾曲線
二次貝塞爾曲線由三個點P0
,P1
,P2
來確定,這些點也被稱作控制點。曲線的方程為:
這個方程其實有它的幾何意義,它表示可以通過這樣的步驟來繪製一條曲線:
- 選定一個
0-1
的t
值 - 通過
P0
和P1
計算出點Q0
,Q0
在P0
P1
連成的直線上,並且length( P0, Q0 ) = length( P0, P1 ) * t
- 同樣,通過
P1
和P2
計算出Q1
,使得length( P1, Q1 ) = length( P1, P2 ) * t
- 再重複一次這個步驟,通過
Q1
和Q2
計算出B
,使得length( Q0, Q1 ) = length( Q0, B ) * t
。B
就為當前曲線上的點
注:上面的length
表示兩點之間的長度
有了曲線方程,我們直接代入具體的t
值就能算出點B
了。
如果將t
的值從0
過渡到1
,不斷計算點B
,就可以得到一條二次貝塞爾曲線:
在canvas中,繪製二次貝塞爾曲線的方法為
ctx.quadraticCurveTo( p1x, p1y, p2x, p2y )
複製程式碼
其中p1x, p1y, p2x, p2y
為後兩個控制點(P1
和P2
)的橫縱座標,它預設將當前路徑的起點作為一個控制點(P0
)。
三次貝塞爾曲線
三次貝塞爾曲線需要四個點P0
,P1
,P2
,P3
來確定,曲線方程為
同樣,將t
的值從0
過渡到1
,就可以繪製出一條三次貝塞爾曲線:
在canvas中,繪製三次貝塞爾曲線的方法為
ctx.bezierCurveTo( p1x, p1y, p2x, p2y, p3x, p3y )
複製程式碼
其中p1x, p1y, p2x, p2y, p3x, p3y
為後三個控制點(P1
,P2
和P3
)的橫縱座標,它預設將當前路徑的起點作為一個控制點(P0
)。
貝塞爾曲線的特徵
在三次貝塞爾曲線後面,還有更高階的貝塞爾曲線,同樣它們繪製的過程也更加複雜
四次貝塞爾曲線
五次貝塞爾曲線
我們可以歸納出貝塞爾曲線有幾個重要的特徵:
- n階貝塞爾曲線需要n+1個點來確定
- 貝塞爾曲線是平滑的
- 貝塞爾曲線的起點和終點與對應控制點的連線相切
繪製貝塞爾曲線
複習完基礎概念,接下來就要講如果繪製貝塞爾曲線啦
為簡單起見,我們選擇使用二次貝塞爾曲線。
我們先不考慮動畫的事,我們先將問題簡化成:給定一個起點和一個終點,需要實現一個函式,它能夠繪製出一條曲線。
也就是說我們需要實現一個函式drawCurvePath
,除渲染上下文ctx外(不清楚ctx是什麼的同學可以先熟悉下canvas的基本概念),它接受三個引數,分別為二次貝塞爾曲線的三個控制點。我們將樣式控制移到函式外,drawCurvePath
只用來繪製路徑。
/**
* 繪製二次貝賽爾曲線路徑
* @param {Object} ctx
* @param {Array<number>} p0
* @param {Array<number>} p1
* @param {Array<number>} p2
*/
function drawCurvePath( ctx, p0, p1, p2 ) {
// ...
}
複製程式碼
前文提到過,在canvas中,繪製二次貝賽爾曲線的方法是quadraticCurveTo
,所以只要短短兩行就能完成這個方法。
/**
* 繪製二次貝賽爾曲線路徑
* @param {CanvasRenderingContext2D} ctx
* @param {Array<number>} p0
* @param {Array<number>} p1
* @param {Array<number>} p2
*/
function drawCurvePath( ctx, p0, p1, p2 ) {
ctx.moveTo( p0[ 0 ], p0[ 1 ] );
ctx.quadraticCurveTo(
p1[ 0 ], p1[ 1 ],
p2[ 0 ], p2[ 1 ]
);
}
複製程式碼
這樣就完成了基本的繪製二次貝塞爾曲線的方法了。
但是函式這樣設計有點小問題
如果我們是在做一個圖形庫,我們想給使用者提供一個繪製曲線的方法。
對於使用者來說,他只想在給定的起點和終點間間繪製一條曲線,他想要得到的曲線儘量美觀,但是又不想關心具體的實現細節,如果還需要給第三個點,使用者會有一定的學習成本(至少需要弄明白什麼是貝塞爾曲線)。
看到這裡你可能會比較疑惑,即使是二次貝塞爾曲線也需要三個控制點,只有起點和終點怎麼繪製曲線呢。
我們可以在起點和終點的垂直平分線上選一點作為第三個控制點,可以提供給使用者一個引數來控制曲線的彎曲程度,現在函式就變成了這樣
/**
* 繪製一條曲線路徑
* @param {CanvasRenderingContext2D} ctx
* @param {Array<number>} start 起點
* @param {Array<number>} end 終點
* @param {number} curveness 曲度(0-1)
*/
function drawCurvePath( ctx, start, end, curveness ) {
// ...
}
複製程式碼
我們用curveness
來表示曲線的彎曲程度,也就是第三個控制點的偏離程度。這樣很容易就能計算出中間點。
現在完整的函式變成了這樣:
/**
* 繪製一條曲線路徑
* @param {Object} ctx canvas渲染上下文
* @param {Array<number>} start 起點
* @param {Array<number>} end 終點
* @param {number} curveness 曲度(0-1)
*/
function drawCurvePath( ctx, start, end, curveness ) {
// 計算中間控制點
var cp = [
( start[ 0 ] + end[ 0 ] ) / 2 - ( start[ 1 ] - end[ 1 ] ) * curveness,
( start[ 1 ] + end[ 1 ] ) / 2 - ( end[ 0 ] - start[ 0 ] ) * curveness
];
ctx.moveTo( start[ 0 ], start[ 1 ] );
ctx.quadraticCurveTo(
cp[ 0 ], cp[ 1 ],
end[ 0 ], end[ 1 ]
);
}
複製程式碼
對,就這麼短短几行,接下來我們就可以通過它來繪製一條曲線了,程式碼如下
<!DOCTYPE html>
<html lang="en">
<head>
<title>draw curve</title>
</head>
<body>
<canvas id="canvas" width="800" height="800"></canvas>
<script>
var canvas = document.getElementById( 'canvas' );
var ctx = canvas.getContext( '2d' );
ctx.lineWidth = 2;
ctx.strokeStyle = '#ff0000';
ctx.beginPath();
drawCurvePath(
ctx,
[ 100, 100 ],
[ 200, 300 ],
0.2
);
ctx.stroke();
function drawCurvePath( ctx, start, end, curveness ) {
// ...
}
</script>
</body>
</html>
複製程式碼
繪製貝塞爾曲線動畫
終於來到文章的本體啦,我們的目的不是繪製一條靜態的曲線,我們想繪製一條有過渡效果的曲線。
簡化一下問題,那就是我們希望繪製曲線的函式還接受另一個引數,表示繪製曲線的百分比。我們定時去呼叫這個函式,遞增百分比這個引數,就能畫出動畫了。
我們新增一個引數percent
來表示百分比,現在函式變成了這樣:
/**
* 繪製一條曲線路徑
* @param {Object} ctx canvas渲染上下文
* @param {Array<number>} start 起點
* @param {Array<number>} end 終點
* @param {number} curveness 曲度(0-1)
* @param {number} percent 繪製百分比(0-100)
*/
function drawCurvePath( ctx, start, end, curveness, percent ) {
// ...
}
複製程式碼
但是canvas提供的quadraticCurveTo
方法只能繪製一條完整的二次貝賽爾曲線,沒有辦法去控制它只畫一部分。
畫完後用clearRect
擦除掉一部分?這不太可行,因為很難確定要擦除的範圍。如果曲線的線寬比較寬,就還需要保證擦除的邊界和曲線末端垂直,問題就變得很複雜了。
現在再重新看看這張圖
我們是不是可以將percent
這個引數理解成t
值,然後通過貝賽爾曲線方程去計算出中間所有的點,用直線連線起來,以此模擬繪製貝賽爾曲線的一部分呢?
方法一
我們不再用canvas提供的quadraticCurveTo
來繪製曲線,而是通過貝賽爾曲線的方程計算出一系列點,用多段直線來模擬曲線。
這樣做的好處是,我們可以很容易的控制繪製的範圍。
那麼函式實現就變成了這樣:
/**
* 繪製一條曲線路徑
* @param {Object} ctx canvas渲染上下文
* @param {Array<number>} start 起點
* @param {Array<number>} end 終點
* @param {number} curveness 曲度(0-1)
* @param {number} percent 繪製百分比(0-100)
*/
function drawCurvePath( ctx, start, end, curveness, percent ) {
var cp = [
( start[ 0 ] + end[ 0 ] ) / 2 - ( start[ 1 ] - end[ 1 ] ) * curveness,
( start[ 1 ] + end[ 1 ] ) / 2 - ( end[ 0 ] - start[ 0 ] ) * curveness
];
ctx.moveTo( start[ 0 ], start[ 1 ] );
for ( var t = 0; t <= percent / 100; t += 0.01 ) {
var x = quadraticBezier( start[ 0 ], cp[ 0 ], end[ 0 ], t );
var y = quadraticBezier( start[ 1 ], cp[ 1 ], end[ 1 ], t );
ctx.lineTo( x, y );
}
}
function quadraticBezier( p0, p1, p2, t ) {
var k = 1 - t;
return k * k * p0 + 2 * ( 1 - t ) * t * p1 + t * t * p2; // 這個方程就是二次貝賽爾曲線方程
}
複製程式碼
接下來就可以通過設定定時器,每隔一段時間呼叫一次這個方法,並且遞增percent
為了動畫更加平滑,我們使用requestAnimationFrame
來代替定時器
<!DOCTYPE html>
<html lang="en">
<head>
<title>draw curve</title>
</head>
<body>
<canvas id="canvas" width="800" height="800"></canvas>
<script>
var canvas = document.getElementById( 'canvas' );
var ctx = canvas.getContext( '2d' );
ctx.lineWidth = 2;
ctx.strokeStyle = '#000';
var percent = 0;
function animate() {
ctx.clearRect( 0, 0, 800, 800 );
ctx.beginPath();
drawCurvePath(
ctx,
[ 100, 100 ],
[ 200, 300 ],
0.2,
percent
);
ctx.stroke();
percent = ( percent + 1 ) % 100;
requestAnimationFrame( animate );
}
animate();
function drawCurvePath( ctx, start, end, curveness, percent ) {
// ...
}
</script>
</body>
</html>
複製程式碼
得到的結果:
這樣基本實現了我們的需求,但它有一個問題:
測試發現,進行一次lineTo
的時間和一次quadraticCurveTo
的時間差不多,但是quadraticCurveTo
只需要一次就能畫出曲線,而使用lineTo
則需要數十次。
換言之,用這樣的方式繪製曲線,和我們前面的實現方式相比效能下降了數十倍之多。在繪製一條曲線時可能感覺不到區別,但是如果需要同時繪製上千條曲線,效能就會受到很大的影響。
方法二
那有沒有什麼方法可以做到用quadraticCurveTo
來實現繪製完整曲線的一部分呢?
我們再次回到這張圖
在中間的某一時刻,例如t=0.25時,它是這樣的:
我們注意到,曲線P0-B
這一段似乎也是貝賽爾曲線,它的控制點變成了P0,Q0,B
。
現在問題就迎刃而解了,我們只需要每次計算出Q0,B
,就能得到其中一小段貝賽爾曲線的控制點,然後就可以通過quadraticCurveTo
來繪製它了。
程式碼如下:
/**
* 繪製一條曲線路徑
* @param {Object} ctx canvas渲染上下文
* @param {Array<number>} start 起點
* @param {Array<number>} end 終點
* @param {number} curveness 曲度(0-1)
* @param {number} percent 繪製百分比(0-100)
*/
function drawCurvePath( ctx, start, end, curveness, percent ) {
var cp = [
( start[ 0 ] + end[ 0 ] ) / 2 - ( start[ 1 ] - end[ 1 ] ) * curveness,
( start[ 1 ] + end[ 1 ] ) / 2 - ( end[ 0 ] - start[ 0 ] ) * curveness
];
var t = percent / 100;
var p0 = start;
var p1 = cp;
var p2 = end;
var v01 = [ p1[ 0 ] - p0[ 0 ], p1[ 1 ] - p0[ 1 ] ]; // 向量<p0, p1>
var v12 = [ p2[ 0 ] - p1[ 0 ], p2[ 1 ] - p1[ 1 ] ]; // 向量<p1, p2>
var q0 = [ p0[ 0 ] + v01[ 0 ] * t, p0[ 1 ] + v01[ 1 ] * t ];
var q1 = [ p1[ 0 ] + v12[ 0 ] * t, p1[ 1 ] + v12[ 1 ] * t ];
var v = [ q1[ 0 ] - q0[ 0 ], q1[ 1 ] - q0[ 1 ] ]; // 向量<q0, q1>
var b = [ q0[ 0 ] + v[ 0 ] * t, q0[ 1 ] + v[ 1 ] * t ];
ctx.moveTo( p0[ 0 ], p0[ 1 ] );
ctx.quadraticCurveTo(
q0[ 0 ], q0[ 1 ],
b[ 0 ], b[ 1 ]
);
}
複製程式碼
將前面寫的頁面替換成上面的程式碼,可以看到得到的結果是一樣的:
繪製動畫
現在已經解決了最關鍵的問題,我們可以繪製動畫啦。 不過這一部分並不重要,我就不貼程式碼了。
完整程式碼可以看這裡
結束
這篇部落格到這裡就結束了,我們講了canvas如何繪製曲線,以及如何繪製曲線過渡效果。下篇部落格我計劃寫一寫如何在canvas中模擬大自然中光源照射在物體表面產生的效果。
我的部落格地址: github.com/hujiulong/b…
我會在這裡分享我的學習成果和經驗,特別是canvas/WebGL/svg這方面的技術。如果有對前端圖形繪製感興趣的同學可以關注一下我的部落格,收藏點star,訂閱點watch。
最近才將部落格搬到github,所以文章並不多,我會堅持寫下去的!