基於Java
程式碼實現,並附有相應的Kotlin
版本
原創文章,轉載請聯絡作者
軟草平莎過雨新,輕沙走馬路無塵。
何時收拾耦耕身?
先上效果圖:
筆刷專案地址在此,大家要是喜歡的話,不妨來點個贊吧
效果解析
因為最終要實現的是windwos
下的畫板噴漆筆刷,所以首先要對它做一個較為詳細的效果解析。考慮到筆一般情況下筆刷的使用點,故此會分析一下點和線的效果細節。
- 畫點
從左至右依次是對同一座標點選2次,點選8次,點選16次的效果展示;
當數量趨向更大時,點的密集程度並沒有很明顯的偏向,基本可以確定要在圓內均勻分佈
- 畫線
如圖為勻速且緩慢滑過時,由點構成線
具體實現
專案的大致框架由View
、BasePen
,兩個大的模組構成。其中View
屬於UI層面,BasePen
屬於業務邏輯層面。接下來,將一一介紹這兩個模組的具體功用和細節。
View
此專案的承載View為PenView
,不承擔業務邏輯,就是起到一個容器的作用。在PenView
中唯一的作用就是觸發invalidate()
方法。
private BasePen mBasePen;
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (w != 0 && h != 0) {
if (mBasePen == null) {
mBasePen = new SprayPen(w, h);
}
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
MotionEvent event1 = MotionEvent.obtain(event);
mBasePen.onTouchEvent(event1);
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
invalidate();
break;
case MotionEvent.ACTION_UP:
break;
}
return true;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mBasePen.onDraw(canvas);
}
複製程式碼
具體的業務邏輯,繪製、資料計算、觸控點移動Move等,全都由BasePen
以及它的子類來實現了。
低耦合性,代表著更多的自由度,對現有專案程式碼(如果應用到專案中)的衝擊更小。在效能方面,如果View
滿足不了要求,可以用更小的代價將其移植到效能更好的SurfaceView
裡去。
業務邏輯
業務方面,BasePen
作為基類,承擔了一些基礎的資料計算、繪製等功能,而具體的畫筆效果則交由子類實現。
先看看BasePen
裡做了什麼:
- 繪製
private List<Point> mPoints;
public void onDraw(Canvas canvas) {
if (mPoints != null && !mPoints.isEmpty()) {
canvas.drawBitmap(mBitmap, 0, 0, null);
drawDetail(canvas);
}
}
複製程式碼
先將筆刷繪製到一張Bitmap
之上,再將這張Bitmap
交給PenView
來繪製出來。Point
是一個只記錄了x和y座標的類。drawDetail(Canvas canvas)
是一個抽象類,由子類實現具體的繪製。
- 滑動軌跡
在
BasePen
的onTouchEvent(MotionEvent event1)
方法裡。以每次DOWN
事件為開始,記錄MOVE
內的所有座標資訊。考慮到噴漆效果基本不用處理筆鋒效果,暫不考慮記錄UP
資訊(後續如果實現其他筆刷效果會優化這裡)。
public void onTouchEvent(MotionEvent event1) {
switch (event1.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
clearPoints();
handlePoints(event1);
break;
case MotionEvent.ACTION_MOVE:
handlePoints(event1);
break;
case MotionEvent.ACTION_UP:
break;
}
}
private void handlePoints(MotionEvent event1) {
float x = event1.getX();
float y = event1.getY();
if (x > 0 && y > 0) {
mPoints.add(new Point(x, y));
}
}
private void clearPoints() {
if (mPoints == null) {
return;
}
mPoints.clear();
}
複製程式碼
- 噴漆實現
protected void drawDetail(Canvas canvas) {
if (getPoints().isEmpty()) {
return;
}
mTotalNum = 由自定義粒子密度以及畫筆寬度計算而來
drawSpray(當前最新座標點.x, 當前最新座標點.y, mTotalNum);
}
private void drawSpray(float x, float y, int totalNum) {
for (int i = 0; i < totalNum; i++) {
//演算法計算出圓內隨機點
float[] randomPoint = getRandomPoint(x, y, mPenW, true);
mCanvas.drawCircle(randomPoint[0], randomPoint[1], mCricleR, mPaint);
}
}
複製程式碼
以上是一部分虛擬碼,
SprayPen
內部定義了一個噴漆粒子密度,會根據畫筆的寬度來實時改變粒子數量。每個粒子的半徑則由外部依賴的元件提供的width
計算而來。
在drawDetail(...)
方法內,每一次MOVE
和DOWN
事件都會在相應座標處,繪製一定數目的圓內隨機點。
當其串聯起來時,就形成了噴漆效果。當然這只是初步完成,還有一些演算法需要完善。虛擬碼表述不全,可參考SprayPen,在程式碼中有比較完善的註釋。
接下來會說一些有關噴漆演算法方面的問題。
噴漆演算法的幾個問題
在實現功能的過程中,有兩個問題是值得記錄的。
一是圓內均勻隨機點的分佈問題;二是滑動速度快時,筆畫的連線處理問題。
如何均勻的在圓內生成隨機點
為了解決這個問題,主要嘗試了三種方法:
x在(-R,R)範圍內隨機取值,由圓解析式求解得y。然後對y在(-y,y)內隨機取值,得到的點即為圓內點。同理,也可由y計算出x。
java程式碼如下:
float x = mRandom.nextInt(r);
float y = (float) Math.sqrt(Math.pow(r, 2) - Math.pow(x, 2));
y = mRandom.nextInt((int) y);
x = 對值隨機取正負(x);
y = 對值隨機取正負(y);
複製程式碼
最終呈現效果如下:
當樣本數量達到2000時,形狀如上所示
可以很明顯的看到,在x軸方向,左右兩端的密集程度明顯高於圓心
隨機值在大量資料下會具有規律性,可以理解為當資料很多時,x的取值在(-r,r)大致為均勻分佈的,y的取值亦是。當處於左右兩端時,y的取值範圍變小,視覺效果就顯得緊湊了些。
當然如果用概率論數理統計公式來驗證會更有說服力,但可惜不會。。。(聳肩)
隨機角度,在[0,360)內隨機取得角度,然後在[0,r]範圍內隨機取值,然後使用sin
和cos
來求解x和y。
java程式碼如下:
float[] ints = new float[2];
int degree = mRandom.nextInt(360);
double curR = mRandom.nextInt(r)+1;
float x = (float) (curR * Math.cos(Math.toRadians(degree)));
float y = (float) (curR * Math.sin(Math.toRadians(degree)));
x = 對值隨機取正負(x);
y = 對值隨機取正負(y);
複製程式碼
最終呈現效果如下:
明顯看到中心處的密集程度高於邊緣地帶,事實上當角度固定時,r在[0,R)範圍內隨機取值。當數量更大時,座標點是均勻分佈的。
當r越小時,所佔用的面積越小,就會顯得粒子很密集。
隨機角度,在[0,360)內隨機取得角度,取[0,1]內的隨機平方根再和R相乘,然後使用sin
和cos
來求解x和y。
java程式碼如下:
int degree = mRandom.nextInt(360);
double curR = Math.sqrt(mRandom.nextDouble()) * r;
float x = (float) (curR * Math.cos(Math.toRadians(degree)));
float y = (float) (curR * Math.sin(Math.toRadians(degree)));
x = 對值隨機取正負(x);
y = 對值隨機取正負(y);
複製程式碼
最終呈現效果如下:
這次的視覺效果總算是達到了均勻的效果,這個演算法是利用了一個根函式的特性,如下圖:
紅色是根函式,藍色是線性函式。兩者相比下來,根函式的取值會更大些,相應的,接近邊緣的點就會更多一點,讓粒子的分佈效果更加均衡。
處理“奮筆疾書”情況
當以比較慢的速度滑動時,筆畫尚顯流暢無明顯斷層。當速度過快時,MOVE
留下的點更少,且間距大。會出現畫筆斷層現象,這時候就需要一些特殊的處理方法。
程式碼中設定了一個標準值D
,這個值是由BasePen
所持有的w和h兩個值計算而來的,一般來說,這兩個值期望為依附的View
的寬高。最初也考慮使用畫筆的直徑計算,但考慮到畫筆直徑是可以外部動態改變的。標準值最好保持一定的獨立性,其所依賴的資料越穩定越好,要不然會影響平衡
。然後當MOVE
時,當前點距離上一個點的相對距離大於這個標準值D
時,就會判定此時處於快移速狀態,間距越大移速越快,那麼噴漆效果相應地就要減弱【直觀而言就是粒子濃度要低】。
快移速狀態時,程式碼會在當前點和上一個點之間,模擬出一些筆跡點。相應地,這些筆跡點的粒子密集度會低一些,其計算函式且是一個反駝峰的變化狀態。即連續筆跡點的中間點粒子最稀疏,兩邊則最密集。
//手速過快時
float stepDis = mPenR * 1.6f;
//筆跡點的數量
int v = (int) (getLastDis() / stepDis);
float gapX = getPoints().get(getPoints().size() - 1).x - getPoints().get(getPoints().size() - 2).x;
float gapY = getPoints().get(getPoints().size() - 1).y - getPoints().get(getPoints().size() - 2).y;
//描繪筆跡點
for (int i = 1; i <= v; i++) {
float x = (float) (getPoints().get(getPoints().size() - 2).x + (gapX * i * stepDis / getLastDis()));
float y = (float) (getPoints().get(getPoints().size() - 2).y + (gapY * i * stepDis / getLastDis()));
drawSpray(x, y, (int) (mTotalNum * calculate(i, 1, v)), mRandom.nextBoolean());
}
/**
* 使用(x-(min+max)/2)^2/(min-(min+max)/2)^2作為粒子密度比函式
*/
private static float calculate(int index, int min, int max) {
float maxProbability = 0.6f;
float minProbability = 0.15f;
if (max - min + 1 <= 4) {
return maxProbability;
}
int mid = (max + min) / 2;
int maxValue = (int) Math.pow(mid - min, 2);
float ratio = (float) (Math.pow(index - mid, 2) / maxValue);
if (ratio >= maxProbability) {
return maxProbability;
} else if (ratio <= minProbability) {
return minProbability;
} else {
return ratio;
}
}
複製程式碼
Kotlin
本專案在寫的時候,順便也寫了一個Kotlin
版本的。注意,並不是用AS自帶的程式碼轉換的。所以Kotlin
版本會有很多不必要的測試體驗程式碼,不要在意這些細節。
Kotlin版本這裡這裡,喜歡的不妨點個贊吧
總結
以上就是本次Demo
的思路、以及一些演算法的解析。數學之美,令人沉醉*(數學學渣留下了悔恨的淚水。。。)*
數學才是本體啊
筆刷專案地址在此,程式碼中的註釋會更加清晰些,大家要是喜歡的話,不妨來點個贊吧
有歡迎關注我的公眾號,技術與生活