貝塞爾曲線開發的藝術
一句話概括貝塞爾曲線:將任意一條曲線轉化為精確的數學公式。
很多繪圖工具中的鋼筆工具,就是典型的貝塞爾曲線的應用,這裡的一個網站可以線上模擬鋼筆工具的使用:
貝塞爾曲線中有一些比較關鍵的名詞,解釋如下:
- 資料點:通常指一條路徑的起始點和終止點
- 控制點:控制點決定了一條路徑的彎曲軌跡,根據控制點的個數,貝塞爾曲線被分為一階貝塞爾曲線(0個控制點)、二階貝塞爾曲線(1個控制點)、三階貝塞爾曲線(2個控制點)等等。
要想對貝塞爾曲線有一個比較好的認識,可以參考WIKI上的連結:
https://en.wikipedia.org/wiki/B%C3%A9zier_curve
貝塞爾曲線模擬
在Android中,一般來說,開發者只考慮二階貝塞爾曲線和三階貝塞爾曲線,SDK也只提供了二階和三階的API呼叫。對於再高階的貝塞爾曲線,通常可以將曲線拆分成多個低階的貝塞爾曲線,也就是所謂的降階操作。下面將通過程式碼來模擬二階和三階的貝塞爾曲線是如何繪製和控制的。
貝塞爾曲線的一個比較好的動態演示如下所示:
http://myst729.github.io/bezier-curve/
二階模擬
二階貝塞爾曲線在Android中的API為:quadTo()和rQuadTo(),這兩個API在原理上是可以互相轉換的——quadTo是基於絕對座標,而rQuadTo是基於相對座標,所以後面我都只以其中一個來進行講解。
先來看下最終的效果:
從前面的介紹可以知道,二階貝塞爾曲線有兩個資料點和一個控制點,只需要在程式碼中繪製出這些輔助點和輔助線即可,同時,控制點可以通過onTouchEvent來進行傳遞。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
package com.xys.animationart.views; import android.content.Context; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Path; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; /** * 二階貝塞爾曲線 * <p/> * Created by xuyisheng on 16/7/11. */ public class SecondOrderBezier extends View { private Paint mPaintBezier; private Paint mPaintAuxiliary; private Paint mPaintAuxiliaryText; private float mAuxiliaryX; private float mAuxiliaryY; private float mStartPointX; private float mStartPointY; private float mEndPointX; private float mEndPointY; private Path mPath = new Path(); public SecondOrderBezier(Context context) { super(context); } public SecondOrderBezier(Context context, AttributeSet attrs) { super(context, attrs); mPaintBezier = new Paint(Paint.ANTI_ALIAS_FLAG); mPaintBezier.setStyle(Paint.Style.STROKE); mPaintBezier.setStrokeWidth(8); mPaintAuxiliary = new Paint(Paint.ANTI_ALIAS_FLAG); mPaintAuxiliary.setStyle(Paint.Style.STROKE); mPaintAuxiliary.setStrokeWidth(2); mPaintAuxiliaryText = new Paint(Paint.ANTI_ALIAS_FLAG); mPaintAuxiliaryText.setStyle(Paint.Style.STROKE); mPaintAuxiliaryText.setTextSize(20); } public SecondOrderBezier(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); mStartPointX = w / 4; mStartPointY = h / 2 - 200; mEndPointX = w / 4 * 3; mEndPointY = h / 2 - 200; } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); mPath.reset(); mPath.moveTo(mStartPointX, mStartPointY); // 輔助點 canvas.drawPoint(mAuxiliaryX, mAuxiliaryY, mPaintAuxiliary); canvas.drawText("控制點", mAuxiliaryX, mAuxiliaryY, mPaintAuxiliaryText); canvas.drawText("起始點", mStartPointX, mStartPointY, mPaintAuxiliaryText); canvas.drawText("終止點", mEndPointX, mEndPointY, mPaintAuxiliaryText); // 輔助線 canvas.drawLine(mStartPointX, mStartPointY, mAuxiliaryX, mAuxiliaryY, mPaintAuxiliary); canvas.drawLine(mEndPointX, mEndPointY, mAuxiliaryX, mAuxiliaryY, mPaintAuxiliary); // 二階貝塞爾曲線 mPath.quadTo(mAuxiliaryX, mAuxiliaryY, mEndPointX, mEndPointY); canvas.drawPath(mPath, mPaintBezier); } @Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_MOVE: mAuxiliaryX = event.getX(); mAuxiliaryY = event.getY(); invalidate(); } return true; } } |
三階模擬
三階貝塞爾曲線在Android中的API為:cubicTo()和rCubicTo(),這兩個API在原理上是可以互相轉換的——quadTo是基於絕對座標,而rCubicTo是基於相對座標,所以後面我都只以其中一個來進行講解。
有了二階的基礎,再來模擬三階就非常簡單了,無非是增加了一個控制點而已,先看下效果圖:
程式碼只需要在二階的基礎上新增一些輔助點即可,下面只給出一些關鍵程式碼,詳細程式碼請參考Github:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); mPath.reset(); mPath.moveTo(mStartPointX, mStartPointY); // 輔助點 canvas.drawPoint(mAuxiliaryOneX, mAuxiliaryOneY, mPaintAuxiliary); canvas.drawText("控制點1", mAuxiliaryOneX, mAuxiliaryOneY, mPaintAuxiliaryText); canvas.drawText("控制點2", mAuxiliaryTwoX, mAuxiliaryTwoY, mPaintAuxiliaryText); canvas.drawText("起始點", mStartPointX, mStartPointY, mPaintAuxiliaryText); canvas.drawText("終止點", mEndPointX, mEndPointY, mPaintAuxiliaryText); // 輔助線 canvas.drawLine(mStartPointX, mStartPointY, mAuxiliaryOneX, mAuxiliaryOneY, mPaintAuxiliary); canvas.drawLine(mEndPointX, mEndPointY, mAuxiliaryTwoX, mAuxiliaryTwoY, mPaintAuxiliary); canvas.drawLine(mAuxiliaryOneX, mAuxiliaryOneY, mAuxiliaryTwoX, mAuxiliaryTwoY, mPaintAuxiliary); // 三階貝塞爾曲線 mPath.cubicTo(mAuxiliaryOneX, mAuxiliaryOneY, mAuxiliaryTwoX, mAuxiliaryTwoY, mEndPointX, mEndPointY); canvas.drawPath(mPath, mPaintBezier); } |
模擬網頁
如下所示的網頁,模擬了三階貝塞爾曲線的繪製,可以通過拖動曲線來獲取兩個控制點的座標,而起始點分別是(0,0)和(1,1)。
通過這個網頁,也可以比較方便的獲取三階貝塞爾曲線的控制點座標。
貝塞爾曲線應用
圓滑繪圖
當在螢幕上繪製路徑時,例如手寫板,最基本的方法是通過Path.lineTo將各個觸點連線起來,而這種方式在很多時候會發現,兩個點的連線是非常生硬的,因為它畢竟是通過直線來連線的,如果通過二階貝塞爾曲線來將各個觸點連線,就會圓滑的多,不會出現太多的生硬連線。
先來看下程式碼,非常簡單的繪製路徑程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
package com.xys.animationart.views; import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Path; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; /** * 圓滑路徑 * <p/> * Created by xuyisheng on 16/7/19. */ public class DrawPadBezier extends View { private float mX; private float mY; private float offset = ViewConfiguration.get(getContext()).getScaledTouchSlop(); private Paint mPaint; private Path mPath; public DrawPadBezier(Context context) { super(context); } public DrawPadBezier(Context context, AttributeSet attrs) { super(context, attrs); mPath = new Path(); mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mPaint.setStyle(Paint.Style.STROKE); mPaint.setStrokeWidth(5); mPaint.setColor(Color.RED); } public DrawPadBezier(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: mPath.reset(); float x = event.getX(); float y = event.getY(); mX = x; mY = y; mPath.moveTo(x, y); break; case MotionEvent.ACTION_MOVE: float x1 = event.getX(); float y1 = event.getY(); float preX = mX; float preY = mY; float dx = Math.abs(x1 - preX); float dy = Math.abs(y1 - preY); if (dx >= offset || dy >= offset) { // 貝塞爾曲線的控制點為起點和終點的中點 float cX = (x1 + preX) / 2; float cY = (y1 + preY) / 2; // mPath.quadTo(preX, preY, cX, cY); mPath.lineTo(x1, y1); mX = x1; mY = y1; } } invalidate(); return true; } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); canvas.drawPath(mPath, mPaint); } } |
先來看下通過mPath.lineTo來實現的繪圖,效果如下所示:
圖片中的拐點有明顯的鋸齒效果,即通過直線的連線,再來看下通過貝塞爾曲線來連線的效果,通常情況下,貝塞爾曲線的控制點取兩個連續點的中點:
1 |
mPath.quadTo(preX, preY, cX, cY); |
通過二階貝塞爾曲線的連線效果如圖所示:
可以明顯的發現,曲線變得更加圓滑了。
曲線變形
通過控制貝塞爾曲線的控制點,就可以實現對一條路徑的修改。所以,利用貝塞爾曲線,可以實現很多的路徑動畫,例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
package com.xys.animationart; import android.animation.ValueAnimator; import android.content.Context; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Path; import android.util.AttributeSet; import android.view.View; import android.view.animation.BounceInterpolator; /** * 曲線變形 * <p/> * Created by xuyisheng on 16/7/11. */ public class PathMorphBezier extends View implements View.OnClickListener{ private Paint mPaintBezier; private Paint mPaintAuxiliary; private Paint mPaintAuxiliaryText; private float mAuxiliaryOneX; private float mAuxiliaryOneY; private float mAuxiliaryTwoX; private float mAuxiliaryTwoY; private float mStartPointX; private float mStartPointY; private float mEndPointX; private float mEndPointY; private Path mPath = new Path(); private ValueAnimator mAnimator; public PathMorphBezier(Context context) { super(context); } public PathMorphBezier(Context context, AttributeSet attrs) { super(context, attrs); mPaintBezier = new Paint(Paint.ANTI_ALIAS_FLAG); mPaintBezier.setStyle(Paint.Style.STROKE); mPaintBezier.setStrokeWidth(8); mPaintAuxiliary = new Paint(Paint.ANTI_ALIAS_FLAG); mPaintAuxiliary.setStyle(Paint.Style.STROKE); mPaintAuxiliary.setStrokeWidth(2); mPaintAuxiliaryText = new Paint(Paint.ANTI_ALIAS_FLAG); mPaintAuxiliaryText.setStyle(Paint.Style.STROKE); mPaintAuxiliaryText.setTextSize(20); setOnClickListener(this); } public PathMorphBezier(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); mStartPointX = w / 4; mStartPointY = h / 2 - 200; mEndPointX = w / 4 * 3; mEndPointY = h / 2 - 200; mAuxiliaryOneX = mStartPointX; mAuxiliaryOneY = mStartPointY; mAuxiliaryTwoX = mEndPointX; mAuxiliaryTwoY = mEndPointY; mAnimator = ValueAnimator.ofFloat(mStartPointY, (float) h); mAnimator.setInterpolator(new BounceInterpolator()); mAnimator.setDuration(1000); mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { mAuxiliaryOneY = (float) valueAnimator.getAnimatedValue(); mAuxiliaryTwoY = (float) valueAnimator.getAnimatedValue(); invalidate(); } }); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); mPath.reset(); mPath.moveTo(mStartPointX, mStartPointY); // 輔助點 canvas.drawPoint(mAuxiliaryOneX, mAuxiliaryOneY, mPaintAuxiliary); canvas.drawText("輔助點1", mAuxiliaryOneX, mAuxiliaryOneY, mPaintAuxiliaryText); canvas.drawText("輔助點2", mAuxiliaryTwoX, mAuxiliaryTwoY, mPaintAuxiliaryText); canvas.drawText("起始點", mStartPointX, mStartPointY, mPaintAuxiliaryText); canvas.drawText("終止點", mEndPointX, mEndPointY, mPaintAuxiliaryText); // 輔助線 canvas.drawLine(mStartPointX, mStartPointY, mAuxiliaryOneX, mAuxiliaryOneY, mPaintAuxiliary); canvas.drawLine(mEndPointX, mEndPointY, mAuxiliaryTwoX, mAuxiliaryTwoY, mPaintAuxiliary); canvas.drawLine(mAuxiliaryOneX, mAuxiliaryOneY, mAuxiliaryTwoX, mAuxiliaryTwoY, mPaintAuxiliary); // 三階貝塞爾曲線 mPath.cubicTo(mAuxiliaryOneX, mAuxiliaryOneY, mAuxiliaryTwoX, mAuxiliaryTwoY, mEndPointX, mEndPointY); canvas.drawPath(mPath, mPaintBezier); } @Override public void onClick(View view) { mAnimator.start(); } } |
這裡就是簡單的改變二階貝塞爾曲線的控制點來實現曲線的變形。
網上一些比較複雜的變形動畫效果,也是基於這種實現方式,其原理都是通過改變控制點的位置,從而達到對圖形的變換,例如圓形到心形的變化、圓形到五角星的變換,等等。
波浪效果
波浪的繪製是貝塞爾曲線一個非常簡單的應用,而讓波浪進行波動,其實並不需要對控制點進行改變,而是可以通過位移來實現,這裡我們是藉助貝塞爾曲線來實現波浪的繪製效果,效果如圖所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
package com.xys.animationart.views; import android.animation.ValueAnimator; import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Path; import android.util.AttributeSet; import android.view.View; import android.view.animation.LinearInterpolator; /** * 波浪圖形 * <p/> * Created by xuyisheng on 16/7/11. */ public class WaveBezier extends View implements View.OnClickListener { private Paint mPaint; private Path mPath; private int mWaveLength = 1000; private int mOffset; private int mScreenHeight; private int mScreenWidth; private int mWaveCount; private int mCenterY; public WaveBezier(Context context) { super(context); } public WaveBezier(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } public WaveBezier(Context context, AttributeSet attrs) { super(context, attrs); mPath = new Path(); mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mPaint.setColor(Color.LTGRAY); mPaint.setStyle(Paint.Style.FILL_AND_STROKE); setOnClickListener(this); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); mScreenHeight = h; mScreenWidth = w; mWaveCount = (int) Math.round(mScreenWidth / mWaveLength + 1.5); mCenterY = mScreenHeight / 2; } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); mPath.reset(); mPath.moveTo(-mWaveLength + mOffset, mCenterY); for (int i = 0; i < mWaveCount; i++) { // + (i * mWaveLength) // + mOffset mPath.quadTo((-mWaveLength * 3 / 4) + (i * mWaveLength) + mOffset, mCenterY + 60, (-mWaveLength / 2) + (i * mWaveLength) + mOffset, mCenterY); mPath.quadTo((-mWaveLength / 4) + (i * mWaveLength) + mOffset, mCenterY - 60, i * mWaveLength + mOffset, mCenterY); } mPath.lineTo(mScreenWidth, mScreenHeight); mPath.lineTo(0, mScreenHeight); mPath.close(); canvas.drawPath(mPath, mPaint); } @Override public void onClick(View view) { ValueAnimator animator = ValueAnimator.ofInt(0, mWaveLength); animator.setDuration(1000); animator.setRepeatCount(ValueAnimator.INFINITE); animator.setInterpolator(new LinearInterpolator()); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { mOffset = (int) animation.getAnimatedValue(); postInvalidate(); } }); animator.start(); } } |
波浪動畫實際上並不複雜,但三角函式確實對一些開發者比較困難,開發者可以通過下面的這個網站來模擬三角函式影象的繪製:
https://www.desmos.com/calculator
路徑動畫
貝塞爾曲線的另一個非常常用的功能,就是作為動畫的運動軌跡,讓動畫目標能夠沿曲線平滑的實現移動動畫,也就是讓物體沿著貝塞爾曲線運動,而不是機械的直線,本例實現效果如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 |
package com.xys.animationart.views; import android.animation.ValueAnimator; import android.content.Context; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.Path; import android.graphics.PointF; import android.util.AttributeSet; import android.view.View; import android.view.animation.AccelerateDecelerateInterpolator; import com.xys.animationart.evaluator.BezierEvaluator; /** * 貝塞爾路徑動畫 * <p/> * Created by xuyisheng on 16/7/12. */ public class PathBezier extends View implements View.OnClickListener { private Paint mPathPaint; private Paint mCirclePaint; private int mStartPointX; private int mStartPointY; private int mEndPointX; private int mEndPointY; private int mMovePointX; private int mMovePointY; private int mControlPointX; private int mControlPointY; private Path mPath; public PathBezier(Context context) { super(context); } public PathBezier(Context context, AttributeSet attrs) { super(context, attrs); mPathPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mPathPaint.setStyle(Paint.Style.STROKE); mPathPaint.setStrokeWidth(5); mCirclePaint = new Paint(Paint.ANTI_ALIAS_FLAG); mStartPointX = 100; mStartPointY = 100; mEndPointX = 600; mEndPointY = 600; mMovePointX = mStartPointX; mMovePointY = mStartPointY; mControlPointX = 500; mControlPointY = 0; mPath = new Path(); setOnClickListener(this); } public PathBezier(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); mPath.reset(); canvas.drawCircle(mStartPointX, mStartPointY, 30, mCirclePaint); canvas.drawCircle(mEndPointX, mEndPointY, 30, mCirclePaint); mPath.moveTo(mStartPointX, mStartPointY); mPath.quadTo(mControlPointX, mControlPointY, mEndPointX, mEndPointY); canvas.drawPath(mPath, mPathPaint); canvas.drawCircle(mMovePointX, mMovePointY, 30, mCirclePaint); } @Override public void onClick(View view) { BezierEvaluator bezierEvaluator = new BezierEvaluator(new PointF(mControlPointX, mControlPointY)); ValueAnimator anim = ValueAnimator.ofObject(bezierEvaluator, new PointF(mStartPointX, mStartPointY), new PointF(mEndPointX, mEndPointY)); anim.setDuration(600); anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { PointF point = (PointF) valueAnimator.getAnimatedValue(); mMovePointX = (int) point.x; mMovePointY = (int) point.y; invalidate(); } }); anim.setInterpolator(new AccelerateDecelerateInterpolator()); anim.start(); } } |
其中,用於改變運動點座標的關鍵evaluator如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
package com.xys.animationart.evaluator; import android.animation.TypeEvaluator; import android.graphics.PointF; import com.xys.animationart.util.BezierUtil; public class BezierEvaluator implements TypeEvaluator<PointF> { private PointF mControlPoint; public BezierEvaluator(PointF controlPoint) { this.mControlPoint = controlPoint; } @Override public PointF evaluate(float t, PointF startValue, PointF endValue) { return BezierUtil.CalculateBezierPointForQuadratic(t, startValue, mControlPoint, endValue); } } |
這裡的TypeEvaluator計算用到了計算貝塞爾曲線上點的計算演算法,這個會在後面繼續講解。
貝塞爾曲線進階
求貝塞爾曲線上任意一點的座標
求貝塞爾曲線上任意一點的座標,這一過程,就是利用了De Casteljau演算法。
http://www.cs.mtu.edu/~shene/COURSES/cs3621/NOTES/spline/Bezier/de-casteljau.html
利用這一演算法,有開發者開發了一個演示多階貝塞爾曲線的效果的App,其原理就是通過繪製貝塞爾曲線上的點來進行繪製的,地址如下所示:
https://github.com/venshine/BezierMaker
下面這篇文章就詳細的講解了該演算法的應用,我的程式碼也從這裡提取而來:
http://devmag.org.za/2011/04/05/bzier-curves-a-tutorial/
計算
有了公式,只需要程式碼實現就OK了,我們先寫兩個公式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
package com.xys.animationart.util; import android.graphics.PointF; /** * 計算貝塞爾曲線上的點座標 * <p/> * Created by xuyisheng on 16/7/13. */ public class BezierUtil { /** * B(t) = (1 - t)^2 * P0 + 2t * (1 - t) * P1 + t^2 * P2, t ∈ [0,1] * * @param t 曲線長度比例 * @param p0 起始點 * @param p1 控制點 * @param p2 終止點 * @return t對應的點 */ public static PointF CalculateBezierPointForQuadratic(float t, PointF p0, PointF p1, PointF p2) { PointF point = new PointF(); float temp = 1 - t; point.x = temp * temp * p0.x + 2 * t * temp * p1.x + t * t * p2.x; point.y = temp * temp * p0.y + 2 * t * temp * p1.y + t * t * p2.y; return point; } /** * B(t) = P0 * (1-t)^3 + 3 * P1 * t * (1-t)^2 + 3 * P2 * t^2 * (1-t) + P3 * t^3, t ∈ [0,1] * * @param t 曲線長度比例 * @param p0 起始點 * @param p1 控制點1 * @param p2 控制點2 * @param p3 終止點 * @return t對應的點 */ public static PointF CalculateBezierPointForCubic(float t, PointF p0, PointF p1, PointF p2, PointF p3) { PointF point = new PointF(); float temp = 1 - t; point.x = p0.x * temp * temp * temp + 3 * p1.x * t * temp * temp + 3 * p2.x * t * t * temp + p3.x * t * t * t; point.y = p0.y * temp * temp * temp + 3 * p1.y * t * temp * temp + 3 * p2.y * t * t * temp + p3.y * t * t * t; return point; } } |
我們來將路徑繪製到View中,看是否正確:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
package com.xys.animationart.views; import android.animation.AnimatorSet; import android.animation.ValueAnimator; import android.content.Context; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.PointF; import android.util.AttributeSet; import android.view.View; import com.xys.animationart.util.BezierUtil; /** * 通過計算模擬二階、三階貝塞爾曲線 * <p/> * Created by xuyisheng on 16/7/13. */ public class CalculateBezierPointView extends View implements View.OnClickListener { private Paint mPaint; private ValueAnimator mAnimatorQuadratic; private ValueAnimator mAnimatorCubic; private PointF mPointQuadratic; private PointF mPointCubic; public CalculateBezierPointView(Context context) { super(context); } public CalculateBezierPointView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } public CalculateBezierPointView(Context context, AttributeSet attrs) { super(context, attrs); mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mAnimatorQuadratic = ValueAnimator.ofFloat(0, 1); mAnimatorQuadratic.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { PointF point = BezierUtil.CalculateBezierPointForQuadratic(valueAnimator.getAnimatedFraction(), new PointF(100, 100), new PointF(500, 100), new PointF(500, 500)); mPointQuadratic.x = point.x; mPointQuadratic.y = point.y; invalidate(); } }); mAnimatorCubic = ValueAnimator.ofFloat(0, 1); mAnimatorCubic.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { PointF point = BezierUtil.CalculateBezierPointForCubic(valueAnimator.getAnimatedFraction(), new PointF(100, 600), new PointF(100, 1100), new PointF(500, 1000), new PointF(500, 600)); mPointCubic.x = point.x; mPointCubic.y = point.y; invalidate(); } }); mPointQuadratic = new PointF(); mPointQuadratic.x = 100; mPointQuadratic.y = 100; mPointCubic = new PointF(); mPointCubic.x = 100; mPointCubic.y = 600; setOnClickListener(this); } @Override protected void onDraw(final Canvas canvas) { super.onDraw(canvas); canvas.drawCircle(mPointQuadratic.x, mPointQuadratic.y, 10, mPaint); canvas.drawCircle(mPointCubic.x, mPointCubic.y, 10, mPaint); } @Override public void onClick(View view) { AnimatorSet set = new AnimatorSet(); set.playTogether(mAnimatorQuadratic, mAnimatorCubic); set.setDuration(2000); set.start(); } } |
這次我們並沒有通過API提供的貝塞爾曲線繪製方法來繪製二階、三階貝塞爾曲線,而是通過時間t和起始點來計算一條貝塞爾曲線上的所有點,可以發現,通過演算法計算出來的點,與通過API所繪製出來的點,是完全吻合的。
貝塞爾曲線擬合計算
貝塞爾曲線有一個非常常用的動畫效果——MetaBall演算法。相信很多開發者都見過類似的動畫,例如QQ的小紅點消除,UC瀏覽器的下拉重新整理loading等等。要做好這個動畫,實際上最重要的就是通過貝塞爾曲線來擬合兩個圖形。
效果如圖所示:
矩形擬合
我們來看一下擬合的原理,實際上就是通過貝塞爾曲線來連線兩個圓上的四個點,當我們調整下畫筆的填充方式,並繪製一些輔助線,我們來看具體是如何進行擬合的,如圖所示:
可以發現,控制點為兩圓圓心連線的中點,連線線為圖中的這樣一個矩形,當圓比較小時,這種通過矩形來擬合的方式幾乎是沒有問題的,但我們把圓放大,再來看下這種擬合,如圖所示:
當圓的半徑擴大之後,就可以非常明顯的發現擬合的連線點與圓有一定相交的區域,這樣的擬合效果就不好了,我們將畫筆模式調整回來,如圖所示:
所以,簡單的矩形擬合,在圓半徑小的時候,是可以的,但當圓半徑變大之後,就需要更加嚴格的擬合了。
這裡我們先來講解下,如何計算矩形擬合的幾個關鍵點。
從前面那張線圖可以看出,標紅的兩個角是相等的,而這個角可以通過兩個圓心的座標來算出,有了這樣一個角度,通過R x cos和 R x sin來計算矩形的一個頂點的座標,類似的,其它座標可求,關鍵程式碼如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
private void metaBallVersion1(Canvas canvas) { float x = mCircleTwoX; float y = mCircleTwoY; float startX = mCircleOneX; float startY = mCircleOneY; float dx = x - startX; float dy = y - startY; double a = Math.atan(dx / dy); float offsetX = (float) (mCircleOneRadius * Math.cos(a)); float offsetY = (float) (mCircleOneRadius * Math.sin(a)); float x1 = startX + offsetX; float y1 = startY - offsetY; float x2 = x + offsetX; float y2 = y - offsetY; float x3 = x - offsetX; float y3 = y + offsetY; float x4 = startX - offsetX; float y4 = startY + offsetY; float controlX = (startX + x) / 2; float controlY = (startY + y) / 2; mPath.reset(); mPath.moveTo(x1, y1); mPath.quadTo(controlX, controlY, x2, y2); mPath.lineTo(x3, y3); mPath.quadTo(controlX, controlY, x4, y4); mPath.lineTo(x1, y1); // 輔助線 canvas.drawLine(mCircleOneX, mCircleOneY, mCircleTwoX, mCircleTwoY, mPaint); canvas.drawLine(0, mCircleOneY, mCircleOneX + mRadiusNormal + 400, mCircleOneY, mPaint); canvas.drawLine(mCircleOneX, 0, mCircleOneX, mCircleOneY + mRadiusNormal + 50, mPaint); canvas.drawLine(x1, y1, x2, y2, mPaint); canvas.drawLine(x3, y3, x4, y4, mPaint); canvas.drawCircle(controlX, controlY, 5, mPaint); canvas.drawLine(mCircleTwoX, mCircleTwoY, mCircleTwoX, 0, mPaint); canvas.drawLine(x1, y1, x1, mCircleOneY, mPaint); canvas.drawPath(mPath, mPaint); } |
切線擬合
如前面所說,矩形擬合在半徑較小的情況下,是可以實現完美擬合的,而當半徑變大後,就會出現貝塞爾曲線與圓相交的情況,導致擬合失敗。
那麼如何來實現完美的擬合呢?實際上,也就是說貝塞爾曲線與圓的連線點到貝塞爾曲線的控制點的連線,一定是圓的切線,這樣的話,無論圓的半徑如何變化,貝塞爾曲線一定是與圓擬合的,具體效果如圖所示:
這時候我們把畫筆模式調整回來看下填充效果,如圖所示:
這樣擬合是非常完美的。那麼要如何來計算這些擬合的關鍵點呢?在前面的線圖中,我標記出了兩個角,這兩個角分別可以求出,相減,就可以獲取切點與圓心的夾角了,這樣,通過R x cos和R x sin就可以求出切點的座標了。
其中,小的角可以通過兩個圓心的座標來求出,而大的角,可以通過直角三角形(圓心、切點、控制點)來求出,即控制點到圓心的距離/半徑。
關鍵程式碼如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
private void metaBallVersion2(Canvas canvas) { float x = mCircleTwoX; float y = mCircleTwoY; float startX = mCircleOneX; float startY = mCircleOneY; float controlX = (startX + x) / 2; float controlY = (startY + y) / 2; float distance = (float) Math.sqrt((controlX - startX) * (controlX - startX) + (controlY - startY) * (controlY - startY)); double a = Math.acos(mRadiusNormal / distance); double b = Math.acos((controlX - startX) / distance); float offsetX1 = (float) (mRadiusNormal * Math.cos(a - b)); float offsetY1 = (float) (mRadiusNormal * Math.sin(a - b)); float tanX1 = startX + offsetX1; float tanY1 = startY - offsetY1; double c = Math.acos((controlY - startY) / distance); float offsetX2 = (float) (mRadiusNormal * Math.sin(a - c)); float offsetY2 = (float) (mRadiusNormal * Math.cos(a - c)); float tanX2 = startX - offsetX2; float tanY2 = startY + offsetY2; double d = Math.acos((y - controlY) / distance); float offsetX3 = (float) (mRadiusNormal * Math.sin(a - d)); float offsetY3 = (float) (mRadiusNormal * Math.cos(a - d)); float tanX3 = x + offsetX3; float tanY3 = y - offsetY3; double e = Math.acos((x - controlX) / distance); float offsetX4 = (float) (mRadiusNormal * Math.cos(a - e)); float offsetY4 = (float) (mRadiusNormal * Math.sin(a - e)); float tanX4 = x - offsetX4; float tanY4 = y + offsetY4; mPath.reset(); mPath.moveTo(tanX1, tanY1); mPath.quadTo(controlX, controlY, tanX3, tanY3); mPath.lineTo(tanX4, tanY4); mPath.quadTo(controlX, controlY, tanX2, tanY2); canvas.drawPath(mPath, mPaint); // 輔助線 canvas.drawCircle(tanX1, tanY1, 5, mPaint); canvas.drawCircle(tanX2, tanY2, 5, mPaint); canvas.drawCircle(tanX3, tanY3, 5, mPaint); canvas.drawCircle(tanX4, tanY4, 5, mPaint); canvas.drawLine(mCircleOneX, mCircleOneY, mCircleTwoX, mCircleTwoY, mPaint); canvas.drawLine(0, mCircleOneY, mCircleOneX + mRadiusNormal + 400, mCircleOneY, mPaint); canvas.drawLine(mCircleOneX, 0, mCircleOneX, mCircleOneY + mRadiusNormal + 50, mPaint); canvas.drawLine(mCircleTwoX, mCircleTwoY, mCircleTwoX, 0, mPaint); canvas.drawCircle(controlX, controlY, 5, mPaint); canvas.drawLine(startX, startY, tanX1, tanY1, mPaint); canvas.drawLine(tanX1, tanY1, controlX, controlY, mPaint); } |
圓的擬合
貝塞爾曲線做動畫,很多時候都需要使用到圓的特效,而通過二階、三階貝塞爾曲線來擬合圓,也不是一個非常簡單的事情,所以,我直接把結論拿出來了,具體的演算法地址如下所示:
http://spencermortensen.com/articles/bezier-circle/
http://stackoverflow.com/questions/1734745/how-to-create-circle-with-b%C3%A9zier-curves
有了貝塞爾曲線的控制點,再對其實現動畫,就非常簡單了,與之前的動畫沒有太大的區別。
原始碼
本次的講解程式碼已經全部上傳到Github :
https://github.com/xuyisheng/BezierArt
歡迎大家提issue。
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!