仿Windows畫板噴漆筆刷效果

AiLo發表於2018-06-03

基於Java程式碼實現,並附有相應的Kotlin版本
原創文章,轉載請聯絡作者

軟草平莎過雨新,輕沙走馬路無塵。
何時收拾耦耕身?

先上效果圖:

仿Windows畫板噴漆筆刷效果

筆刷專案地址在此,大家要是喜歡的話,不妨來點個贊吧

效果解析

因為最終要實現的是windwos下的畫板噴漆筆刷,所以首先要對它做一個較為詳細的效果解析。考慮到筆一般情況下筆刷的使用點,故此會分析一下的效果細節。

  • 畫點
    仿Windows畫板噴漆筆刷效果

從左至右依次是對同一座標點選2次,點選8次,點選16次的效果展示;
當數量趨向更大時,點的密集程度並沒有很明顯的偏向,基本可以確定要在圓內均勻分佈

  • 畫線
    仿Windows畫板噴漆筆刷效果

如圖為勻速且緩慢滑過時,由點構成線

具體實現

專案的大致框架由ViewBasePen,兩個大的模組構成。其中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)是一個抽象類,由子類實現具體的繪製。

  • 滑動軌跡 在BasePenonTouchEvent(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(...)方法內,每一次MOVEDOWN事件都會在相應座標處,繪製一定數目的圓內隨機點。
當其串聯起來時,就形成了噴漆效果。當然這只是初步完成,還有一些演算法需要完善。虛擬碼表述不全,可參考SprayPen,在程式碼中有比較完善的註釋。

接下來會說一些有關噴漆演算法方面的問題。

噴漆演算法的幾個問題

在實現功能的過程中,有兩個問題是值得記錄的。
一是圓內均勻隨機點的分佈問題;二是滑動速度快時,筆畫的連線處理問題。

如何均勻的在圓內生成隨機點

為了解決這個問題,主要嘗試了三種方法:

x在(-R,R)範圍內隨機取值,由圓解析式
仿Windows畫板噴漆筆刷效果
求解得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);
複製程式碼

最終呈現效果如下:

仿Windows畫板噴漆筆刷效果

當樣本數量達到2000時,形狀如上所示
可以很明顯的看到,在x軸方向,左右兩端的密集程度明顯高於圓心
隨機值在大量資料下會具有規律性,可以理解為當資料很多時,x的取值在(-r,r)大致為均勻分佈的,y的取值亦是。當處於左右兩端時,y的取值範圍變小,視覺效果就顯得緊湊了些。
當然如果用概率論數理統計公式來驗證會更有說服力,但可惜不會。。。(聳肩)

隨機角度,在[0,360)內隨機取得角度,然後在[0,r]範圍內隨機取值,然後使用sincos來求解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);
複製程式碼

最終呈現效果如下:

仿Windows畫板噴漆筆刷效果

明顯看到中心處的密集程度高於邊緣地帶,事實上當角度固定時,r在[0,R)範圍內隨機取值。當數量更大時,座標點是均勻分佈的。
當r越小時,所佔用的面積越小,就會顯得粒子很密集。

隨機角度,在[0,360)內隨機取得角度,取[0,1]內的隨機平方根再和R相乘,然後使用sincos來求解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);
複製程式碼

最終呈現效果如下:

仿Windows畫板噴漆筆刷效果

這次的視覺效果總算是達到了均勻的效果,這個演算法是利用了一個根函式的特性,如下圖:

仿Windows畫板噴漆筆刷效果
紅色是根函式,藍色是線性函式。兩者相比下來,根函式的取值會更大些,相應的,接近邊緣的點就會更多一點,讓粒子的分佈效果更加均衡。

處理“奮筆疾書”情況

當以比較慢的速度滑動時,筆畫尚顯流暢無明顯斷層。當速度過快時,MOVE留下的點更少,且間距大。會出現畫筆斷層現象,這時候就需要一些特殊的處理方法。
程式碼中設定了一個標準值D,這個值是由BasePen所持有的wh兩個值計算而來的,一般來說,這兩個值期望為依附的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的思路、以及一些演算法的解析。數學之美,令人沉醉*(數學學渣留下了悔恨的淚水。。。)*
數學才是本體啊
筆刷專案地址在此,程式碼中的註釋會更加清晰些,大家要是喜歡的話,不妨來點個贊吧

有歡迎關注我的公眾號,技術與生活

仿Windows畫板噴漆筆刷效果

參考資料:

相關文章