深入淺出貝塞爾曲線

語言: CN / TW / HK

貝塞爾曲線的定義及推導過程

貝塞爾曲線於1962年,由法國工程師皮埃爾·貝茲(Pierre Bézier)所廣泛發表,他運用貝塞爾曲線來為汽車的主體進行設計。貝塞爾曲線最初由保爾·德·卡斯特里奧於1959年運用德卡斯特里奧算法開發,以穩定數值的方法求出貝塞爾曲線。貝塞爾曲線由n個控制點對應着n-1階的貝塞爾曲線,並且可以通過遞歸的方式來繪製。

下面先給出n階貝塞爾曲線的公式

n階.svg

一階貝塞爾曲線

一階動畫.gif

設定圖中運動的點為$P_t$,$t$為運動時間,$t∈(0,1$),可得如下公式

$$ P_t=P_0+\left(P_1-P_0\right)t=\left(1-t\right)P_0+P_1t \tag {1} $$

二階貝塞爾曲線

二階動畫.gif

二階貝塞爾曲線由$P_0$,$P_1$,$P_2$三個點來確定,其中$P_0$為起點,$P_2$為終點,$P_1$為控制點,曲線方程為:

$$ P_t= \left(1-t\right)^2P_0+2t\left(1-t\right)P_1+t^2P_2 \quad\quad t\in\left(0,1\right) $$

二階繪圖.png

  1. 已知三個點$P_0$,$P_1$,$P_2$,連接線段$P_0P_1$和$P_1P_2$,
  2. $P_a$在$P_0P_1$上,隨時間t從$P_0$運動到$P_1$,使得$P_0P_a/P_0P_1=t$;
  3. $P_b$在$P_1P_2$上,隨時間t從$P_1$運動到$P_2$,使得$P_1P_b/P_1P_2=t$;
  4. 連接線段$P_aP_b$,
  5. $P_t$在$P_aP_b$上,隨時間t從$P_a$運動到$P_b$,使得$P_aP_t/P_aP_b=t$;
  6. $t$從0變化到1的過程中,所有$P_t$點就組成了二階貝塞爾曲線。

由公式(1)可得

$$ P_a=P_0+\left(P_1-P_0\right)t=\left(1-t\right)P_0+P_1t \tag {2} $$ $$ P_b=P_1+\left(P_2-P_1\right)t=\left(1-t\right)P_1+P_2t \tag {3} $$ $$ P_t=P_a+\left(P_b-P_a\right)t=\left(1-t\right)P_a+P_bt \tag {4} $$

將公式(2)和公式(3)代入公式(4)可得

$$ \begin{aligned} P_t&=P_a+\left(P_b-P_a\right)t=\left(1-t\right)P_a+P_bt \ &=\left(1-t\right) \left[ \left(1-t\right)P_0+P_1t \right]+t \left[ \left( 1-t\right)P_1+P2t\right]\ &=\left(1-t\right)^2P_0+2t\left(1-t\right)P_1+t^2P_2 \end{aligned} $$

三階貝塞爾曲線

三階動畫.gif

三階貝塞爾曲線由$P_0$,$P_1$,$P_2$,$P_3$四個點來確定,其中$P_0$為起點,$P_3$為終點,$P_1$和$P_2$為控制點,曲線方程為:

$$ P_t= \left(1-t\right)^3P_0+3t\left(1-t\right)^2P_1+3t^2\left(1-t\right)P_2+t^3P_3 \quad\quad t\in\left(0,1\right) $$

三階繪圖.png

  1. 已知三個點$P_0$,$P_1$,$P_2$,$P_3$,連接線段$P_0P_1$、$P_1P_2$以及$P_2P_3$;
  2. $P_a$在$P_0P_1$上,隨時間t從$P_0$運動到$P_1$,使得$P_0P_a/P_0P_1=t$;
  3. $P_b$在$P_1P_2$上,隨時間t從$P_1$運動到$P_2$,使得$P_1P_b/P_1P_2=t$;
  4. $P_c$在$P_2P_3$上,隨時間t從$P_2$運動到$P_3$,使得$P_2P_c/P_2P_3=t$;
  5. 連接線段$P_aP_b$,$P_bP_c$;
  6. $P_d$在$P_aP_b$上,隨時間t從$P_a$運動到$P_b$,使得$P_aP_d/P_aP_b=t$;
  7. $P_e$在$P_bP_c$上,隨時間t從$P_b$運動到$P_c$,使得$P_bP_e/P_bP_c=t$;
  8. $P_t$在$P_dP_e$上,隨時間t從$P_d$運動到$P_e$,使得$P_dP_t/P_dP_e=t$;
  9. $t$從0變化到1的過程中,所有$P_t$點就組成了三階貝塞爾曲線。

由之前的公式可得: $$ \begin{aligned} P_a&=P_0+\left(P_1-P_0\right)t=\left(1-t\right)P_0+P_1t \ P_b&=P_1+\left(P_2-P_1\right)t=\left(1-t\right)P_1+P_2t \ P_c&=P_2+\left(P_3-P_2\right)t=\left(1-t\right)P_2+P_3t \ P_d&=P_a+\left(P_b-P_a\right)t=\left(1-t\right)P_a+P_bt \ P_e&=P_b+\left(P_c-P_b\right)t=\left(1-t\right)P_b+P_ct \ P_t&=P_d+\left(P_e-P_d\right)t=\left(1-t\right)P_d+P_et \ \end{aligned} $$

將上述公式帶入$P_t$可得 $$ \begin{aligned} P_t&= \left(1-t\right)P_d+P_et \ &=\left(1-t\right)\left[\left(1-t\right)P_a+P_bt \right]+t\left[ \left(1-t\right)P_b+P_ct \right]\ &=\left(1-t\right)^2P_a+2t\left(1-t\right)P_b+t^2P_c\ &=\left(1-t\right)^2\left[ \left(1-t\right)P_0+P_1t \right]+2t\left(1-t\right)\left[ \left(1-t\right)P_1+P_2t \right]+t^2\left[ \left(1-t\right)P_2+P_3t \right]\ &=\left(1-t\right)^3P_0+t\left(1-t\right)^2P_1+2t\left(1-t\right)^2P_1+2t^2\left(1-t\right)P_2+t^2\left(1-t\right)P_2+t^3P_3\ &=\left(1-t\right)^3P_0+3t\left(1-t\right)^2P_1+3t^2\left(1-t\right)P_2+t^3P_3 \quad\quad t\in\left(0,1\right) \end{aligned} $$

遞歸性質

仔細觀察上述的構造過程,經過第5步變化之後,三階貝塞爾曲線的求解變成了對以$P_a$為起點,$P_c$為終點,$P_b$為控制點的二階貝塞爾曲線方程的求解。

首先,有四個控制點;
  四個控制點形成三個線段,每個線段上有一個點在運動,於是得到三個點;
  三個控制點形成兩個線段,每個線段上有一個點在運動,於是得到兩個點;
  兩個點形成一個線段,這個線段上有一個點在運動,於是得到一個點;
  最後一個點的運動軌跡便構成了貝塞爾曲線!

我們發現,實際上是每輪都是 n 個點,形成 n-1 條線段,每個線段上有一個點在運動,那麼就只關注這 n-1 個點,循環往復。最終只剩一個點時,它的軌跡便是結果。

這就是我們前面提到的貝塞爾曲線的遞歸性質。

通過對上面貝塞爾曲線的定義及推導過程的閲讀,我們對貝塞爾曲線是什麼以及如何獲得相應階數的曲線公式有了初步的認識,那麼在日常開發中我們如何定義並使用貝塞爾曲線呢?UIBezierPathUIKit中的一個關於圖形繪製的類,是對CGPathRef的封裝,可以方便的讓我們畫出 矩形、橢圓或者直線和曲線的組合形狀。下面我們簡單介紹一下UIBezierPath的常用api。

UIBezierPath

UIBezierPath用於定義一個直線和曲線組合而成的路徑,並且可以在自定義視圖中渲染該路徑。

常用api

一、創建UIBezierPath.

``` objc //創建並返回一個新的bezierPath對象 + (instancetype)bezierPath;

//通過一個矩形rect創建並返回一個矩形bezierPath對象 + (instancetype)bezierPathWithRect:(CGRect)rect;

//通過一個矩形rect創建並返回一個與該矩形內接的橢圓bezierPath對象 + (instancetype)bezierPathWithOvalInRect:(CGRect)rect;

//創建一個圓角矩形路徑,以CGRect為大小,以cornerRadius為圓角半徑 + (instancetype)bezierPathWithRoundedRect:(CGRect)rect cornerRadius:(CGFloat)cornerRadius;

//創建一個圓角矩形路徑,以CGRect為大小,以corners選擇圓角位置,以cornerRadii為圓角半徑 + (instancetype)bezierPathWithRoundedRect:(CGRect)rect byRoundingCorners:(UIRectCorner)corners cornerRadii:(CGSize)cornerRadii;

//創建一個圓弧路徑,以center為圓弧圓心,radius為圓弧半徑,startAngle為圓弧起始角度,endAngle為圓弧終止角度,clockwise為路徑繪製方向,YES:順時針繪製 + (instancetype)bezierPathWithArcCenter:(CGPoint)center radius:(CGFloat)radius startAngle:(CGFloat)startAngle endAngle:(CGFloat)endAngle clockwise:(BOOL)clockwise;

//通過一個已存在的路徑,返回一個該路徑的反轉路徑 - (UIBezierPath *)bezierPathByReversingPath; ```

二、繪製路徑

``` objc //移動path的currentPoint到指定的位置 - (void)moveToPoint:(CGPoint)point;

//在路徑中添加一條直線,從currentPoint開始到指定位置 - (void)addLineToPoint:(CGPoint)point;

//在路徑中添加一條圓弧,以center為圓弧圓心,radius為圓弧半徑,startAngle為圓弧起始角度,endAngle為圓弧終止角度,clockwise為路徑繪製方向,YES:順時針繪製 - (void)addArcWithCenter:(CGPoint)center radius:(CGFloat)radius startAngle:(CGFloat)startAngle endAngle:(CGFloat)endAngle clockwise:(BOOL)clockwise; ```

objc //在路徑中添加一條二階貝塞爾曲線,以endPoint為終點,controlPoint為控制點 - (void)addQuadCurveToPoint:(CGPoint)endPoint controlPoint:(CGPoint)controlPoint;

secondOrder.jpeg

objc //在路徑中添加一條三階貝塞爾曲線,以endPoint為終點,controlPoint1和controlPoint2為兩個控制點, - (void)addCurveToPoint:(CGPoint)endPoint controlPoint1:(CGPoint)controlPoint1 controlPoint2:(CGPoint)controlPoint2;

thirdOrder.jpeg

``` objc //閉合路徑,從currentPoint到路徑起點添加一條直線 - (void)closePath;

//移除路徑上所有點,即刪除所有子路徑 - (void)removeAllPoints;

//在路徑中添加另一條路徑 - (void)appendPath:(UIBezierPath *)bezierPath;

//獲取路徑的不可變CGPathRef對象 @property(nonatomic) CGPathRef CGPath;

//路徑繪製過程中的當前點,即下次繪製的起點,如果路徑為空,該屬性值為CGPointZero @property(nonatomic, readonly) CGPoint currentPoint; ```

三、繪圖屬性

objc //路徑線寬,默認值1.0 @property(nonatomic) CGFloat lineWidth; ``` objc //路徑曲線起點和終點樣式,只對開放路徑起作用,對閉合路徑無效,默認值為 kCGLineCapButt @property(nonatomic) CGLineCap lineCapStyle;

typedef CF_ENUM(int32_t, CGLineCap) { kCGLineCapButt,//方形末端,結束位置在精確位置 kCGLineCapRound,//圓形末端,結束位置超過精確位置半個線寬 kCGLineCapSquare//方形末端,結束位置超過精確位置半個線寬 }; ```

lineCap.jpeg

``` objc //路徑線段的連接點樣式,默認值為 kCGLineJoinMiter @property(nonatomic) CGLineJoin lineJoinStyle;

typedef CF_ENUM(int32_t, CGLineJoin) { kCGLineJoinMiter,//尖角 kCGLineJoinRound,//圓角 kCGLineJoinBevel//切角 }; ```

lineJoin.jpeg

``` objc //使用指定的仿射變換矩陣變換路徑上的所有點 - (void)applyTransform:(CGAffineTransform)transform;

//path調用addClip之後,修改當前圖形上下文的可見繪製區域,接下來的繪製超出path區域的,都會不可見。如果你想在接下來的繪製中移除裁減區域,可以在裁減之前調用CGContextSaveGState保存當前圖形上下文狀態,當不需要裁減區域時,可以通過CGContextRestoreGState恢復 - (void)addClip;

//填充路徑 - (void)fill;

//繪製路徑 - (void)stroke; ```

定義了path之後如何繪製到屏幕上?

  1. 在UIView的- (void)drawRect:(CGRect)rect方法裏面繪製圖形
  2. 使用CAShapeLayer

CAShapeLayerdrawRect比較:
CAShapeLayer:屬於CoreAnimation框架,通過GPU來渲染圖形,節省性能,高效使用內存。
drawRect:屬於Core Graphics框架,佔用大量CPU,耗費性能。

CAShapeLayer

CAShapeLayer繼承自CALayerCAShapeLayer屬於CoreAnimation框架,通過GPU來渲染圖形,節省性能。動畫渲染直接提交給手機GPU,高效使用內存。 每個CAShapeLayer對象都代表着將要被渲染到屏幕上的一個任意的形狀(shape)。具體的形狀由其path(類型為CGPathRef)屬性指定。 普通的CALayer是矩形,需要frame屬性。CAShapeLayer初始化時也需要指定frame值,但它本身沒有形狀,它的形狀來源於其屬性path 。CAShapeLayer中shape需要形狀才能生效。UIBezierPath可以為其提供繪製形狀的path。

常用api

``` objc //圖層渲染的路徑 Animatable,對path進行動畫時,要注意保證前後兩個path擁有相同數量的控制點 @property(nullable) CGPathRef path;

//圖層路徑顏色 @property(nullable) CGColorRef strokeColor;

//路徑渲染的起止相對位置,取值在[0,1]之間,可動畫 @property CGFloat strokeStart; @property CGFloat strokeEnd;

//開始繪製位置在虛線長度中的位置 @property CGFloat lineDashPhase;

//虛線的規格,數組定義了實線和空格的寬度 @property(nullable, copy) NSArray *lineDashPattern;

//線寬,意義同UIBezierPath @property CGFloat lineWidth;

//起點和終點樣式,意義同UIBezierPath @property(copy) CAShapeLayerLineCap lineCap;

//連接點樣式,意義同UIBezierPath @property(copy) CAShapeLayerLineJoin lineJoin; ```

舉個例子

wavegif2.gif

示意圖2.png   下面我們將用二階和三階貝塞爾曲線實現上面的波浪動圖效果,如上示意圖所示,將波浪曲線進行分解,通過繪製兩個相同的完整的“正弦波”,然後不停的將曲線向左側移動和復位,來達到波浪起伏的效果。

二階實現

``` objc // p0p1,p1p2,p2p3,p3p4為4條二階曲線,c1,c2,c3,c4為其相應二階貝塞爾曲線的控制點

  • (void)p_creatWavePath { CGFloat waterHeight = 300.f; CGFloat waveWidth = self.view.frame.size.width / 2.f; CGFloat waveControlHeight = 50.f;

    UIBezierPath *wavePath = [UIBezierPath bezierPath]; [wavePath moveToPoint:CGPointMake(0 - self.waveOffsetX, 0)]; [wavePath addLineToPoint:CGPointMake(0 - self.waveOffsetX, waterHeight)];

    // 計算起點和控制點 CGFloat waveHeight = 20.f; CGPoint p0 = CGPointMake(0 - self.waveOffsetX, waterHeight); CGPoint p1 = CGPointMake(waveWidth - self.waveOffsetX, waterHeight); CGPoint p2 = CGPointMake(waveWidth * 2 - self.waveOffsetX, waterHeight); CGPoint p3 = CGPointMake(waveWidth * 3 - self.waveOffsetX, waterHeight); CGPoint p4 = CGPointMake(waveWidth * 4 - self.waveOffsetX, waterHeight); CGPoint c1 = CGPointMake(waveWidth * 1 / 2 - self.waveOffsetX, waterHeight - waveControlHeight); CGPoint c2 = CGPointMake(waveWidth * 3 / 2 - self.waveOffsetX, waterHeight + waveControlHeight); CGPoint c3 = CGPointMake(waveWidth * 5 / 2 - self.waveOffsetX, waterHeight - waveControlHeight); CGPoint c4 = CGPointMake(waveWidth * 7 / 2 - self.waveOffsetX, waterHeight + waveControlHeight); [wavePath addQuadCurveToPoint:p1 controlPoint:c1]; [wavePath addQuadCurveToPoint:p2 controlPoint:c2]; [wavePath addQuadCurveToPoint:p3 controlPoint:c3]; [wavePath addQuadCurveToPoint:p4 controlPoint:c4];

    [wavePath addLineToPoint:CGPointMake(waveWidth * 4 - self.waveOffsetX, 0)]; [wavePath closePath]; self.waveLayer.path = wavePath.CGPath; // 累加曲線的向左的偏移量 self.waveOffsetX = (self.waveOffsetX + self.waveStepX) % (NSInteger)(self.view.frame.size.width); }

```

三階實現

``` objc // p0p1p2,p2p3p4為兩條三階階曲線,c1和c2,c3和c4為其相應三階貝塞爾曲線的控制點

  • (void)p_creatWavePath { CGFloat waterHeight = 300.f; CGFloat waveWidth = self.view.frame.size.width / 2.f; CGFloat waveControlHeight = 50.f;

    UIBezierPath *wavePath = [UIBezierPath bezierPath]; [wavePath moveToPoint:CGPointMake(0 - self.waveOffsetX, 0)]; [wavePath addLineToPoint:CGPointMake(0 - self.waveOffsetX, waterHeight)];

    // 計算起點和控制點 CGPoint p2 = CGPointMake(waveWidth * 2 - self.waveOffsetX, waterHeight); CGPoint p4 = CGPointMake(waveWidth * 4 - self.waveOffsetX, waterHeight); CGPoint c1 = CGPointMake(waveWidth * 1 / 2 - self.waveOffsetX, waterHeight - waveControlHeight); CGPoint c2 = CGPointMake(waveWidth * 3 / 2 - self.waveOffsetX, waterHeight + waveControlHeight); CGPoint c3 = CGPointMake(waveWidth * 5 / 2 - self.waveOffsetX, waterHeight - waveControlHeight); CGPoint c4 = CGPointMake(waveWidth * 7 / 2 - self.waveOffsetX, waterHeight + waveControlHeight); [wavePath addCurveToPoint:p2 controlPoint1:c1 controlPoint2:c2]; [wavePath addCurveToPoint:p4 controlPoint1:c3 controlPoint2:c4];

    [wavePath addLineToPoint:CGPointMake(waveWidth * 4 - self.waveOffsetX, 0)]; [wavePath closePath]; self.waveLayer.path = wavePath.CGPath; self.waveOffsetX = (self.waveOffsetX + self.waveStepX) % (NSInteger)(self.view.frame.size.width); } ```

反推控制點

由之前的介紹可知,要繪製一條貝塞爾曲線,除了起點和終點外,還必須要知道相應數量的控制點。但在日常開發中我們並不是總能知道控制點,取而代之的是一些曲線經過的點,這個時候要怎麼辦呢?下面以三階曲線為例,推導控制點的計算過程

$$ P_t= \left(1-t\right)^3P_0+3t\left(1-t\right)^2P_1+3t^2\left(1-t\right)P_2+t^3P_3 \quad\quad t\in\left(0,1\right) $$

移動方程式可得:

$$ 3t\left(1-t\right)^2P_1+3t^2\left(1-t\right)P_2 = P_t - \left(1-t\right)^3P_0 - t^3P_3 \quad\quad $$

假設已知三階貝塞爾曲線的起點$P_0$,終點$P_3$,t=1/4時曲線上的點$P_a$,t=3/4時曲線上的點$P_b$
  將t=1/4時的點$P_a$帶入公式可得:

$$ \begin{aligned} 27/64P_1 + 9/64P_2 &= P_a - 27/64P_0 - 1/64P_3 \ 27P_1+9P_2 &= 64P_a-27P_0-P_3 \ \end{aligned} $$   設$P_c=64P_a-27P_0-P_3$,由於$P_a$,$P_0$,$P_3$已知,$P_c$也可以通過計算得出;即 $$ 27P_1+9P_2=P_c \tag {5} $$

同理將t=3/4時的點$P_b$帶入公式可得:

$$ \begin{aligned} 9/64P_1 + 27/64P_2 &= P_b - 1/64P_0 - 27/64P_3 \ 9P_1+27P_2 &= 64P_b-P_0-27P_3 \ \end{aligned} $$

設$P_d=64P_b-P_0-27P_3$, 由於$P_b$,$P_0$,$P_3$已知,$P_d$也可以通過計算得出;即 $$ 9P_1+27P_2 = P_d \tag {6} $$

將公式(5)和公式(6)代入化簡可得:

$$ P_1=\left(3P_a-P_b\right)/72 \ P_2=\left(3P_b-P_a\right)/72 $$

下面是上述例子中控制點求解的函數實現

``` objc // 三階曲線求控制點 // p0:起點,p3:終點,pa:t=t1時曲線上的點,pb:t=t2時曲線上的點 - (NSArray *)p_calculateControlPointsWithPoint0:(CGPoint)p0 pointA:(CGPoint)pa t1:(CGFloat)t1 pointB:(CGPoint)pb t2:(CGFloat)t2 point3:(CGPoint)p3 { // ax + by = c // dx + ey = f // x = (b * f - c * e) / (b * d - a * e) // y = (c * d - a * f) / (b * d - a * e)

CGFloat fa = 3 * t1 * pow((1 - t1), 2);
CGFloat fb = 3 * (1 - t1) * pow(t1, 2);
CGFloat fd = 3 * t2 * pow((1 - t2), 2);
CGFloat fe = 3 * (1 - t2) * pow(t2, 2);

CGFloat fcx = pa.x - pow((1 - t1), 3) * p0.x - pow(t1, 3) * p3.x;
CGFloat fcy = pa.y - pow((1 - t1), 3) * p0.y - pow(t1, 3) * p3.y;
CGFloat ffx = pb.x - pow((1 - t2), 3) * p0.x - pow(t2, 3) * p3.x;
CGFloat ffy = pb.y - pow((1 - t2), 3) * p0.y - pow(t2, 3) * p3.y;

CGPoint p1 = CGPointZero;
CGPoint p2 = CGPointZero;
p1.x = (fb * fcx - ffx * fe) / (fb * fd - fa * fe);
p1.y = (fb * fcy - ffy * fe) / (fb * fd - fa * fe);
p2.x = (fd * fcx - ffx * fa) / (fb * fd - fa * fe);
p2.y = (fd * fcy - ffy * fa) / (fb * fd - fa * fe);

return @[[NSValue valueWithCGPoint:p1], [NSValue valueWithCGPoint:p2]];

} ```

hi, 我是快手電商的鍵盤破風手

快手電商無線技術團隊正在招賢納士🎉🎉🎉! 我們是公司的核心業務線, 這裏雲集了各路高手, 也充滿了機會與挑戰. 伴隨着業務的高速發展, 團隊也在快速擴張. 歡迎各位高手加入我們, 一起創造世界級的電商產品~

熱招崗位: Android/iOS 高級開發, Android/iOS 專家, Java 架構師, 產品經理(電商背景), 測試開發... 大量 HC 等你來呦~

內部推薦請發簡歷至 >>>我們的郵箱: [email protected] <<<, 備註我的花名成功率更高哦~ 😘