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

個人github博客地址 https://github.com/hujiulong/...

前言

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

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

這篇文章我準備從實現一個很是簡單的曲線動畫效果入手,幫助你們完全地弄懂什麼是貝塞爾曲線,以及它有哪些特性,文章中有一點點數學公式,可是都很是簡單:)。css

34359508-9b656938-ea93-11e7-9caf-db0464af2db0.gif實現這樣一個曲線動畫html

能夠點擊這裏查看在線演示前端

在寫代碼以前,先了解一下什麼是貝塞爾曲線吧。java

貝塞爾曲線

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

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

二次貝塞爾曲線

二次貝塞爾曲線由三個點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表示兩點之間的長度echarts

1608e25792da9c97?w=240&h=100&f=png&s=5429圖:二次貝塞爾曲線結構

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

若是將t的值從0過渡到1,不斷計算點B,就能夠獲得一條二次貝塞爾曲線:

1608e1929786355b?w=240&h=100&f=gif&s=74274圖:二次貝塞爾線繪製過程

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

ctx.quadraticCurveTo( p1x, p1y, p2x, p2y )

其中p1x, p1y, p2x, p2y爲後兩個控制點(P1P2)的橫縱座標,它默認將當前路徑的起點做爲一個控制點(P0)。

三次貝塞爾曲線

三次貝塞爾曲線須要四個點P0,P1,P2,P3來肯定,曲線方程爲

它的計算過程和二次貝塞爾曲線相似,這裏再也不贅述,能夠看下圖:

1608e3077df20e7b?w=240&h=100&f=png&s=7942圖:三次貝塞爾曲線結構

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

1608e2ba9c18d8d6?w=240&h=100&f=gif&s=109773圖:三次貝塞爾曲線繪製過程

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

ctx.bezierCurveTo( p1x, p1y, p2x, p2y, p3x, p3y )

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

貝塞爾曲線的特徵

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

四次貝塞爾曲線

圖:四次貝塞爾曲線

五次貝塞爾曲線

1608e389f3e76e8d圖:五次貝塞爾曲線

咱們能夠概括出貝塞爾曲線有幾個重要的特徵:

  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 = '#000';
            ctx.beginPath();
    
            drawCurvePath( 
                ctx,
                [ 100, 100 ],
                [ 200, 300 ],
                0.4
            );
            
            ctx.stroke();
            
            function drawCurvePath( ctx, start, end, curveness ) {
                // ...
            }
        </script>
    </body>
</html>

繪製結果:

qq 20171226233508
繪製一條曲線

繪製貝塞爾曲線動畫

終於來到文章的本體啦,咱們的目的不是繪製一條靜態的曲線,咱們想繪製一條有過渡效果的曲線。

簡化一下問題,那就是咱們但願繪製曲線的函數還接受另外一個參數,表示繪製曲線的百分比。咱們定時去調用這個函數,遞增百分比這個參數,就能畫出動畫了。

咱們新增一個參數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擦除掉一部分?這不太可行,由於很難肯定要擦除的範圍。若是曲線的線寬比較寬,就還須要保證擦除的邊界和曲線末端垂直,問題就變得很複雜了。

如今再從新看看這張圖

1608e1929786355b?w=240&h=100&f=gif&s=74274

咱們是否是能夠將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來實現繪製完整曲線的一部分呢?

咱們再次回到這張圖

s

在中間的某一時刻,例如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 ]
    );

}

將前面寫的頁面替換成上面的代碼,能夠看到獲得的結果是同樣的:

繪製動畫

如今已經解決了最關鍵的問題,咱們能夠繪製動畫啦。
不過這一部分並不重要,我就不貼代碼了。

完整代碼能夠看這裏

160935917f7f0d3f

結束

個人博客地址: https://github.com/hujiulong/...

我會在這裏分享個人學習成果和經驗,特別是canvas/WebGL/svg這方面的技術。若是有對前端圖形繪製感興趣的同窗能夠關注一下個人博客,收藏點star,訂閱點watch

最近纔將博客搬到github,因此文章並很少,我會堅持寫下去的!

相關文章
相關標籤/搜索