用canvas繪製一個曲線動畫——深入理解貝塞爾曲線

莫夭發表於2017-12-27

在前端開發中,貝賽爾曲線無處不在:

  • 它可以用來繪製曲線,在svg和canvas中,原生提供的曲線繪製都是使用貝賽爾曲線
  • 它也可以用來描述一個緩動演算法,設定css的transition-timing-function屬性,可以使用貝塞爾曲線來描述過渡的緩動計算
  • 幾乎所有前端2D或3D圖形圖表庫(echarts,d3,three.js)都會使用到貝塞爾曲線

這篇文章我準備從實現一個非常簡單的曲線動畫效果入手,幫助大家徹底地弄懂什麼是貝塞爾曲線,以及它有哪些特性,文章中有一點點數學公式,但是都非常簡單:)。

160935917f7f0d3f
實現一個曲線動畫

可以點選這裡檢視線上演示

在寫程式碼之前,先了解一下什麼是貝塞爾曲線吧。


貝塞爾曲線

貝塞爾曲線(Bezier curve)是計算機圖形學中相當重要的引數曲線,它通過一個方程來描述一條曲線,根據方程的最高階數,又分為線性貝賽爾曲線,二次貝塞爾曲線、三次貝塞爾曲線和更高階的貝塞爾曲線。

下面詳細介紹一下用得比較多的二次貝塞爾曲線和三次貝塞爾曲線

二次貝塞爾曲線

二次貝塞爾曲線由三個點P0,P1,P2來確定,這些點也被稱作控制點。曲線的方程為:

用canvas繪製一個曲線動畫——深入理解貝塞爾曲線

這個方程其實有它的幾何意義,它表示可以通過這樣的步驟來繪製一條曲線:

  • 選定一個0-1t
  • 通過P0P1計算出點Q0Q0P0 P1連成的直線上,並且length( P0, Q0 ) = length( P0, P1 ) * t
  • 同樣,通過P1P2計算出Q1,使得length( P1, Q1 ) = length( P1, P2 ) * t
  • 再重複一次這個步驟,通過Q1Q2計算出B,使得length( Q0, Q1 ) = length( Q0, B ) * tB就為當前曲線上的點

注:上面的length表示兩點之間的長度

用canvas繪製一個曲線動畫——深入理解貝塞爾曲線

圖:二次貝塞爾曲線結構

有了曲線方程,我們直接代入具體的t值就能算出點B了。

如果將t的值從0過渡到1,不斷計算點B,就可以得到一條二次貝塞爾曲線:

s

圖:二次貝塞爾線繪製過程

在canvas中,繪製二次貝塞爾曲線的方法為

ctx.quadraticCurveTo( p1x, p1y, p2x, p2y )
複製程式碼

其中p1x, p1y, p2x, p2y為後兩個控制點(P1P2)的橫縱座標,它預設將當前路徑的起點作為一個控制點(P0)。

三次貝塞爾曲線

三次貝塞爾曲線需要四個點P0,P1,P2,P3來確定,曲線方程為

用canvas繪製一個曲線動畫——深入理解貝塞爾曲線

它的計算過程和二次貝塞爾曲線類似,這裡不再贅述,可以看下圖:

用canvas繪製一個曲線動畫——深入理解貝塞爾曲線

圖:三次貝塞爾曲線結構

同樣,將t的值從0過渡到1,就可以繪製出一條三次貝塞爾曲線:

用canvas繪製一個曲線動畫——深入理解貝塞爾曲線

圖:三次貝塞爾曲線繪製過程

在canvas中,繪製三次貝塞爾曲線的方法為

ctx.bezierCurveTo( p1x, p1y, p2x, p2y, p3x, p3y )
複製程式碼

其中p1x, p1y, p2x, p2y, p3x, p3y為後三個控制點(P1,P2P3)的橫縱座標,它預設將當前路徑的起點作為一個控制點(P0)。

貝塞爾曲線的特徵

在三次貝塞爾曲線後面,還有更高階的貝塞爾曲線,同樣它們繪製的過程也更加複雜

四次貝塞爾曲線

用canvas繪製一個曲線動畫——深入理解貝塞爾曲線

圖:四次貝塞爾曲線

五次貝塞爾曲線

用canvas繪製一個曲線動畫——深入理解貝塞爾曲線

圖:五次貝塞爾曲線

我們可以歸納出貝塞爾曲線有幾個重要的特徵:

  1. n階貝塞爾曲線需要n+1個點來確定
  2. 貝塞爾曲線是平滑的
  3. 貝塞爾曲線的起點和終點與對應控制點的連線相切

繪製貝塞爾曲線

複習完基礎概念,接下來就要講如果繪製貝塞爾曲線啦

為簡單起見,我們選擇使用二次貝塞爾曲線

我們先不考慮動畫的事,我們先將問題簡化成:給定一個起點和一個終點,需要實現一個函式,它能夠繪製出一條曲線。

也就是說我們需要實現一個函式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擦除掉一部分?這不太可行,因為很難確定要擦除的範圍。如果曲線的線寬比較寬,就還需要保證擦除的邊界和曲線末端垂直,問題就變得很複雜了。

現在再重新看看這張圖

s

我們是不是可以將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>
複製程式碼

得到的結果:

用canvas繪製一個曲線動畫——深入理解貝塞爾曲線

這樣基本實現了我們的需求,但它有一個問題:

測試發現,進行一次lineTo的時間和一次quadraticCurveTo的時間差不多,但是quadraticCurveTo只需要一次就能畫出曲線,而使用lineTo則需要數十次。

換言之,用這樣的方式繪製曲線,和我們前面的實現方式相比效能下降了數十倍之多。在繪製一條曲線時可能感覺不到區別,但是如果需要同時繪製上千條曲線,效能就會受到很大的影響。

方法二

那有沒有什麼方法可以做到用quadraticCurveTo來實現繪製完整曲線的一部分呢?

我們再次回到這張圖

s

在中間的某一時刻,例如t=0.25時,它是這樣的:

用canvas繪製一個曲線動畫——深入理解貝塞爾曲線

我們注意到,曲線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繪製一個曲線動畫——深入理解貝塞爾曲線

繪製動畫

現在已經解決了最關鍵的問題,我們可以繪製動畫啦。 不過這一部分並不重要,我就不貼程式碼了。

完整程式碼可以看這裡

160935917f7f0d3f

結束

這篇部落格到這裡就結束了,我們講了canvas如何繪製曲線,以及如何繪製曲線過渡效果。下篇部落格我計劃寫一寫如何在canvas中模擬大自然中光源照射在物體表面產生的效果。

我的部落格地址: github.com/hujiulong/b…

我會在這裡分享我的學習成果和經驗,特別是canvas/WebGL/svg這方面的技術。如果有對前端圖形繪製感興趣的同學可以關注一下我的部落格,收藏點star,訂閱點watch。

最近才將部落格搬到github,所以文章並不多,我會堅持寫下去的!

相關文章