談談貝塞爾曲線
最近在做項目的時候,需要用到一個動畫,非常簡單的動畫,簡單到就是直接對一個View做平移… 然而雖然動畫簡單,但是卻很不自然,嘗試了UIView Animation提供的各類參數,都無法達到想要的動畫效果。這時候,我的腦子里突然想起一個詞… “貝塞爾曲線”…. 這個詞經常看到,但卻從沒有去了解過,這次就趁著有求于它的雅興,好好做個入門了解好了。
首先,什么是貝塞爾曲線?
顯而易見的是,貝塞爾曲線,應該就是是一個叫貝塞爾的人發明的曲線吧,然而歷史劇本卻不是這么寫的。貝塞爾曲線所依據的最原始的數學公式,是早在1912年就廣為人知的伯恩斯坦多項式。OK,now,What is boensitan duoxiangshi?!簡單來說,伯恩斯坦多項式可以用來證明,在[ a, b ] 區間上所有的連續函數都可以用多項式來逼近,并且收斂性很強,也就是一致收斂。再簡單點,就是一個連續函數,你可以將它寫成若干個伯恩斯坦多項式相加的形式,并且,隨著 n→∞,這個多項式將一致收斂到原函數,這個就是伯恩斯坦斯的逼近性質。
不知道在說什么鬼?沒關系,接著說..
到了1959年,當時就職于雪鐵龍的法國數學家 Paul de Casteljau 開始對伯恩斯坦多項式進行了圖形化的嘗試,并且提供了一種數值穩定的德卡斯特里奧(de Casteljau) 算法。根據這個算法,就可以只通過很少的控制點,去生成復雜的平滑曲線,也就是貝塞爾曲線。
而貝塞爾曲線的得名,得歸功于1962年就職于雷諾的法國工程師皮埃爾·貝塞爾(Pierre Bézier),他使用這種方法來輔助汽車的車體工業設計,并且廣泛宣傳,因此大家才都稱他為貝塞爾曲線 。
貝塞爾曲線是怎么畫出來的?
首先,我們在平面內選3個不同線的點并且依次用線段連接。如下所示..
接著,我們在AB和BC線段上找出點D和點E,使得AD/AB = BE/BC。
再接著,連接DE,并在DE上找出一點F,使得DF/DE = AD/AB = BE/BC。
然后,根據我們高中所學的極限的知識,讓選取的點D在第一條線段上從起點A,移動到終點B,找出所有點F,并將它們連起來。最后你會發現,你得到了一條非常光滑的曲線,這條就是傳說中的,貝塞爾曲線。
看這里…
這是二階貝塞爾曲線。
下面是三階四階和五階。
最后是… 一階….
所以貝塞爾曲線的厲害之處就在這里,從1-n階的連續函數,他都可以計算得到一條光滑曲線。
那么,貝塞爾曲線有什么用?為什么經常會聽到這個名稱?
由于貝塞爾曲線控制簡便,而且它具有很強的描述能力,因此它在工業設計上已經被廣泛使用了。不僅如此,在計算機圖形學領域(特別是矢量圖形學),貝塞爾曲線也有著舉足輕重的地位。而作為程序猿,我們經常會用貝塞爾曲線來繪圖(由貝塞爾曲線畫出來的圖很光滑~),來做動畫(很自然的動畫)等等。也就是由于它可以發揮的作用領域太廣了,因此我們時不時都會聽到這個名字。
好的,那我們要如何用貝塞爾曲線?
首先,要明確的一點是,對于貝塞爾曲線來說,最重要的點是,數據點和控制點。
數據點: 指一條路徑的起始點和終止點。
控制點:控制點決定了一條路徑的彎曲軌跡,根據控制點的個數,貝塞爾曲線被分為一階貝塞爾曲線(0個控制點)、二階貝塞爾曲線(1個控制點)、三階貝塞爾曲線(2個控制點)等等。
而系統給我們提供了一個叫做UIBezierPath類,用它可以畫簡單的圓形,橢圓,矩形,圓角矩形,也可以通過添加點去生成任意的圖形,還可以簡單的創建一條二階貝塞爾曲線和三階貝塞爾曲線。
用法1:簡單地畫圖形
這里的簡單用法就不細講,雖然類名叫UIBezierPath,但畫圓形啥的跟貝塞爾也沒啥關系,直接貼代碼。
畫圓形
UIBezierPath *bPath = [UIBezierPath bezierPathWithArcCenter:CGPointMake(300, 300) radius:50
startAngle: DEGREES_TO_RADIANS(135) endAngle:M_PI*2 clockwise:YES];
[bPath setLineWidth:5];
//繪制
[bPath stroke];
畫橢圓
UIBezierPath *ovalPath = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(200, 150, 100, 200)];
[ovalPath setLineWidth:5];
[ovalPath stroke];
畫矩形
UIBezierPath *myBezierPath = [UIBezierPath bezierPathWithRect:CGRectMake(20, 20, 100, 50)];
[[UIColor blackColor]setStroke];
[myBezierPath setLineWidth:5];
[myBezierPath stroke];
畫圓角矩形
//UIRectCorner可以設置 哪幾個角是圓角,其他不變
UIBezierPath *tBPath = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(220, 20, 100, 100)
byRoundingCorners:UIRectCornerTopLeft | UIRectCornerBottomLeft cornerRadii:CGSizeMake(20, 20)];
[[UIColor greenColor]setStroke];
[tBPath setLineWidth:5];
[tBPath stroke];
通過任意點畫任意圖形
UIBezierPath* aPath = [UIBezierPath bezierPath];
aPath.lineWidth = 15.0;
aPath.lineCapStyle = kCGLineCapButt;? //線條終點
//round 圓形
//butt 平的 默認值 把線連接到精準的終點
//Square 平的,會把線延伸到終點再加上線寬的一半
aPath.lineJoinStyle = kCGLineJoinBevel;? //拐點處理
//bevel 斜角斜面,角的外側是平的不圓滑
//miter 斜接 角的外側是尖的
//round 圓角
//這是起點
[aPath moveToPoint:CGPointMake(100.0, 200.0)];
//添加點
[aPath addLineToPoint:CGPointMake(200.0, 240.0)];
[aPath addLineToPoint:CGPointMake(160, 340)];
[aPath addLineToPoint:CGPointMake(40.0, 340)];
[aPath addLineToPoint:CGPointMake(10.0, 240.0)];
[aPath closePath]; //第五條線通過調用closePath方法得到的
[aPath stroke]; //Draws line 根據坐標點連線
畫二階貝塞爾
UIBezierPath* twoPath = [UIBezierPath bezierPath];
twoPath.lineWidth = 5.0;//寬度
twoPath.lineCapStyle = kCGLineCapRound;? //線條拐角
twoPath.lineJoinStyle = kCGLineJoinRound;? //終點處理
//起始點
[twoPath moveToPoint:CGPointMake(20, 100)];
//添加兩個控制點
[twoPath addQuadCurveToPoint:CGPointMake(220, 100) controlPoint:CGPointMake(170, 0)];
//劃線
[twoPath stroke];
畫三階貝塞爾
UIBezierPath* bPath = [UIBezierPath bezierPath];
bPath.lineWidth = 5.0;
bPath.lineCapStyle = kCGLineCapRound;? //線條拐角
bPath.lineJoinStyle = kCGLineCapRound;? //終點處理
//起始點
[bPath moveToPoint:CGPointMake(20, 250)];
//添加兩個控制點
[bPath addCurveToPoint:CGPointMake(350, 250) controlPoint1:CGPointMake(310, 200) controlPoint2:CGPointMake(210, 400)];
[bPath stroke];
用法2:用貝塞爾曲線圓滑繪圖
這個用法可以說是處女座的福音。
假設這么一個場景:產品提了個需求,來吧,咱們來做一個你畫我猜的APP。你畫我猜?肯定是要先有畫了。簡單!新建個UIView的子類,然后在它的初始化方法中創建Path和手勢。
// Create a path to connect lines
path = [UIBezierPath bezierPath];
// Capture touches
UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(pan:)];
pan.maximumNumberOfTouches = pan.minimumNumberOfTouches = 1;
[self addGestureRecognizer:pan];
再將捕獲到的pan事件location數據依次加入到path中,并且用直線連接兩點。
- (void)pan:(UIPanGestureRecognizer *)pan {
? ? CGPoint currentPoint = [pan locationInView:self];
? ? if (pan.state == UIGestureRecognizerStateBegan) {
? ? ? ? [path moveToPoint:currentPoint];
? ? } else if (pan.state == UIGestureRecognizerStateChanged) {
? ? ? ? [path addLineToPoint:currentPoint];
? ? }
? ? [self setNeedsDisplay];
}
最后畫出軌跡。
- (void)drawRect:(CGRect)rect {
? ? [[UIColor blackColor] setStroke];
? ? [path stroke];
}
最后將這個view添加到控制器上,很開心的Command + R,讓程序跑起來。
開始畫~
然后你就會發現,畫出來的曲線是這樣的。。
WHAT THE FXXK!!
怎么可以有這么多鋸齒。。
所以這個時候,貝塞爾曲線就很有用了。它的定義是可以找到兩點之間的光滑曲線,因為我們之前手勢移動的時候,兩點之間都是使用直線連接,如果我們可以使用貝塞爾曲線連接,那應該就不會出現這個問題了。
試一下。
首先寫一個計算中點的方法,我們到時會使用這個中點作為控制點。
static CGPoint midpoint(CGPoint p0, CGPoint p1) {
? ? return (CGPoint) {
? ? ? ? (p0.x + p1.x) / 2.0,
? ? ? ? (p0.y + p1.y) / 2.0
? ?};
}
最后將手勢處理中的連接方式替換成使用貝塞爾曲線。
復制代碼
- (void)pan:(UIPanGestureRecognizer *)pan {
? ? CGPoint currentPoint = [pan locationInView:self];
? ? CGPoint midPoint = midpoint(previousPoint, currentPoint);
? ? if (pan.state == UIGestureRecognizerStateBegan) {
? ? ? ? [path moveToPoint:currentPoint];
? ? } else if (pan.state == UIGestureRecognizerStateChanged) {
? ? ? ?[path addQuadCurveToPoint:midPoint controlPoint:previousPoint];
? ? }
? ? previousPoint = currentPoint;
? ? [self setNeedsDisplay];
}
再Run一次…
看,光滑多了~
所以很多時候,當我們遇到畫出的圖形太不自然的時候,就可以試著用貝塞爾曲線解決這些問題,用到越高階的曲線,畫出的圖形越光滑。
用法3:用貝塞爾曲線做變形
網上看到的大多數比較酷炫的動畫,都是通過修改曲線的控制點,對曲線進行變形而做的。
比如,我們要實現如下一個動畫。
這個動畫最難地方就是手勢拖拽的時候,直線的變形,可以首先的想到的是使用貝塞爾。通過創建path,添加控制點畫出曲線,然后通過更改控制點的位置來達到讓曲線進行變形的目的。
如上圖所示,這里添加了7個點,從左到右依次為l3、l2、l1、c、 r1、 r2、 r3。屏幕最左和最右兩邊的l3和r3沒有在圖中顯示出來,然后我們就可以以l3和l2為控制點,從l3到l1建立一條二階貝塞爾曲線,再以c和r1為控制點建一條從l3到r1的曲線,最后以r1和r2為控制點建一條從r1到r3的曲線。 主要代碼如下:
- (CGPathRef)currentPath {
? ? CGFloat width = self.view.bounds.size.width;
? ? UIBezierPath *path = [UIBezierPath bezierPath];
? ? [path moveToPoint:CGPointMake(0, 0)];
? ? [path addLineToPoint:CGPointMake(0, self.l3ControlPointView.center.y)];
? ? [path addCurveToPoint:self.l1ControlPointView.center
? ? controlPoint1:self.l3ControlPointView.center
? ? controlPoint2:self.l2ControlPointView.center];
? ? [path addCurveToPoint:self.r1ControlPointView.center
? ? ? ? ? ? ? ? ? ? controlPoint1:self.cControlPointView.center?
? ? ? ? ? ? ? ? ? ?controlPoint2:self.r1ControlPointView.center];?
? ? [path addCurveToPoint:self.r3ControlPointView.center
? ? ? ? ? ? ? ? ? ? controlPoint1:self.r1ControlPointView.center
? ? ? ? ? ? ? ? ? ?controlPoint2:self.r2ControlPointView.center];
? ? [path addLineToPoint:CGPointMake(width, 0)];
? ? [path closePath];
? ? return path.CGPath;
}
建立好路徑之后,就可以通過手勢操作來修改控制點的坐標達到我們的目的了。
在這里也就是修改l3到r3的中心點坐標。主要代碼如下:
- (void)panDidMove:(UIPanGestureRecognizer *)gesture {
? ? if (gesture.state == UIGestureRecognizerStateEnded ||
? ? ? ? gesture.state == UIGestureRecognizerStateFailed ||
? ? ? ? gesture.state == UIGestureRecognizerStateCancelled) {
? ? } else {
? ? ? ? CGFloat additionalHeight = MAX([gesture translationInView:self.view].y, 0);
? ? ? ? CGFloat waveHeight = MIN(additionalHeight*0.6, kMaxWaveHeight);
? ? ? ? CGFloat baseHeight = kMiniHeight + additionalHeight - waveHeight;
? ? ? ? CGFloat locationX = [gesture locationInView:gesture.view].x;
? ? ? ? [self layoutControlPoints:baseHeight waveHeight:waveHeight locationX:locationX];
? ? ? ? [self updateShapeLayer];
? ? }
}
- (void)layoutControlPoints:(CGFloat)baseHeight
? ? ? ? ? ? ? ? ? ? ? ? ?waveHeight:(CGFloat)waveHeight
? ? ? ? ? ? ? ? ? ? ? ? ? ? ?locationX:(CGFloat)locationX {
? ? CGFloat width = self.view.bounds.size.width;
? ? CGFloat minLeftX = MIN(locationX-width/2*0.28, 0);
? ? CGFloat maxRightX = MAX(width+(locationX-width)/2 *0.28, width);
? ? CGFloat leftPartWidth = locationX - minLeftX;
? ? CGFloat rightPartWidth = maxRightX - locationX;
? ? ?self.l3ControlPointView.center = CGPointMake(minLeftX, baseHeight);
? ? self.l2ControlPointView.center = CGPointMake(minLeftX+leftPartWidth*0.44, baseHeight);
? ? self.l1ControlPointView.center = CGPointMake(minLeftX+leftPartWidth*0.71, baseHeight+waveHeight*0.64);
? ?self.cControlPointView.center = CGPointMake(locationX, baseHeight+waveHeight*1.36);
? ? self.r1ControlPointView.center = CGPointMake(maxRightX-rightPartWidth*0.71, ? baseHeight+waveHeight*0.64);
? ? self.r2ControlPointView.center = CGPointMake(maxRightX-(rightPartWidth*0.44), baseHeight);
? ? self.r3ControlPointView.center = CGPointMake(maxRightX, baseHeight);
}
- (void)updateShapeLayer {
? ? self.shapeLayer.path = [self currentPath];
}
通過這個思路,我們可以做出很多有意思而且有生命力的動畫,這里一般還會經常和 CADisplayLink 一起用,先留個坑。
用法4:用貝塞爾曲線做緩沖動畫
做動畫最主要的一點,就是要讓動畫達到很自然的效果。這就要涉及到一些現實中的物理知識,比如重力彈力和速度等等,所以有時候,我們需要對動畫的速度進行控制,有時候需要先快再慢,有時候需要先慢再快然后再慢,有時候又需要快慢超慢非常慢…
這個時候就不得不提到 CAMediaTimingFunction 。
CAMediaTimingFunction 的主要用法可以理解為我們在一個二維坐標系上建議一條或曲線或直線的函數,這個函數的斜率就是動畫的速度,斜率的改變量也就是導數則為加速度。理論上來說,這個坐標系上的任何曲線都可以用來當做加速動畫。然而CAMediaTimingFunction 只給我們提供了一個三次貝塞爾曲線的函數,它可以生成三次貝塞爾曲線所能生成的所有緩沖函數。
這里剛好可以介紹 一個 兩個好用的網站: http://www.roblaplaca.com/examples/bezierBuilder
這個網站可以做到可視化的修改兩個控制點,來達到生成一條三階貝塞爾曲線,并且還會給出兩個控制點的具體坐標,以及右邊還可以看到這條曲線產生的動畫會做怎樣的速度改變。也就是說,只要我們能拿到兩個控制點的坐標,就可以用來控制動畫了。
http://easings.net
這個網站提供了豐富的曲線類型可供選擇,圖表旁還有一個小動畫預覽,非常直觀。
比如下面這段代碼,就可以讓我們把相冊從4:3 切換到1:1 的時候,展示一個先快后慢的過渡效果,這個效果跟系統相機的還是蠻接近的。
CABasicAnimation *animation = [CABasicAnimation animation];
animation.keyPath = @"borderWidth";
animation.repeatCount = 1;
animation.duration = 0.4;
animation.removedOnCompletion = NO;
animation.timingFunction = [CAMediaTimingFunction functionWithControlPoints:0 :1 :1 :1];
animation.fillMode = kCAFillModeForwards;
animation.fromValue = 0.f;
animation.toValue = 40.f;
[self.previewMask addAnimation:animation forKey:@"changeBorderWith"];
效果如下:
用法5:用貝塞爾曲線做擬合計算
貝塞爾曲線有個非常常用的動畫效果,叫MetaBall算法。什么是MetaBall?就是我們平時看到的QQ的小紅點消除啦~ 像下面這樣。
這個是怎么實現的?
矩形擬合
首先我們需要了解一下簡單的矩形擬合原理
如圖所示的兩個圓,我們通過給它添加一個矩形(綠色部分),矩形較短的兩邊分別頂住兩個圓各自的一條直徑上,然后通過改變矩形較長的兩邊的弧度(紅色部分),達到擬合的效果。
這種做法當兩個圓較小的時候,幾乎是沒有問題的。但是當圓稍微大點的時候,就會出現很明顯的相交區域,擬合效果非常不好。
所以這種簡單的矩形擬合在圓較大的時候是很不嚴格的。這個時候就需要更嚴謹的切線擬合。
切線擬合
我們知道,之前的矩形擬合之所以才圓大的時候會出現擬合不嚴謹的情況。為什么?
正如上圖所示,兩條曲線的畫法都是由A1和B1為起點和終點,C點為控制點和A1、B2為起點和終點,C為控制點畫出的二階貝塞爾曲線。
而要做到完美的擬合,必須達到的一點要求就是,貝塞爾曲線與圓的連接點,也就是A1、B1、A2、B2,他們與控制點C的連線,一定要是圓的切線。這樣就不管圓大小怎么變,都不會出現明顯的相交區域了。
圖片引用: http://www.lxweimin.com/p/55c721887568
于是,現在解決問題的關鍵就轉變成了:如何計算這些擬合的關鍵點?
圖片引用: http://pandara.xyz/2015/10/27/ios_slime
我們現在要做的,就是求出點ABCDMN這六個點的坐標,就可以實現完美擬合了。
結合上面兩張圖,通過三角函數的各種計算,我們最終可以得到如下代碼:
- (void)reloadBezierPath {
CGFloat r1 = self.trailDot.frame.size.width / 2.0f;
CGFloat r2 = self.headDot.frame.size.width / 2.0f;
CGFloat x1 = self.trailDot.center.x;
CGFloat y1 = self.trailDot.center.y;
CGFloat x2 = self.headDot.center.x;
CGFloat y2 = self.headDot.center.y;
CGFloat distance = sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1));
CGFloat sinDegree = (x2 - x1) / distance;
CGFloat cosDegree = (y2 - y1) / distance;
CGPoint pointA = CGPointMake(x1 - r1 * cosDegree, y1 + r1 * sinDegree);
CGPoint pointB = CGPointMake(x1 + r1 * cosDegree, y1 - r1 * sinDegree);
CGPoint pointC = CGPointMake(x2 + r2 * cosDegree, y2 - r2 * sinDegree);
CGPoint pointD = CGPointMake(x2 - r2 * cosDegree, y2 + r2 * sinDegree);
CGPoint pointN = CGPointMake(pointB.x + (distance / 2) * sinDegree, pointB.y + (distance / 2) * cosDegree);
CGPoint pointM = CGPointMake(pointA.x + (distance / 2) * sinDegree, pointA.y + (distance / 2) * cosDegree);
UIBezierPath *path = [UIBezierPath bezierPath];
[path moveToPoint:pointA];
[path addLineToPoint:pointB];
[path addQuadCurveToPoint:pointC controlPoint:pointN];
[path addLineToPoint:pointD];
[path addQuadCurveToPoint:pointA controlPoint:pointM];
self.shapeLayer.path = path.CGPath;
}
現在我們已經可以做到非常完美擬合的時候了,這時候再結合前面的通過修改控制點來實現圖形曲線變換,我們就可以做到類似QQ小紅點消除一樣的效果了,具體做法不再贅述。
Ending
至此,我們已基本了解了貝塞爾曲線的歷史出處公式性質及各種用法。在不斷學習的過程中,我發現一些比較牛逼的實現方法,都涉及到了較多較復雜的數學公式,奈何大學高數沒有好好學,導致需要回頭去看很多東西,這也是這篇博客耗費了較多時間的原因之一。不過在掌握了這些基礎和基本用法之后,就可以再去研究一下比較高級和酷炫的用法了,也留下了很多坑,會在以后慢慢填補的…
如果以后還想補的話….
文中如果有什么不足之處歡迎指正,這也是Share的目的之一。