仿Windows畫板噴漆筆刷效果

基於Java代碼實現,並附有相應的Kotlin版本
原創文章,轉載請聯繫做者html

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

先上效果圖:
git

筆刷項目地址在此,你們要是喜歡的話,不妨來點個贊吧github

效果解析

由於最終要實現的是windwos下的畫板噴漆筆刷,因此首先要對它作一個較爲詳細的效果解析。考慮到筆通常狀況下筆刷的使用點,故此會分析一下的效果細節。算法

  • 畫點

從左至右依次是對同一座標點擊2次,點擊8次,點擊16次的效果展現;
當數量趨向更大時,點的密集程度並無很明顯的偏向,基本能夠肯定要在圓內均勻分佈canvas

  • 畫線

如圖爲勻速且緩慢滑過期,由點構成線bash

具體實現

項目的大體框架由ViewBasePen,兩個大的模塊構成。其中View屬於UI層面,BasePen屬於業務邏輯層面。接下來,將一一介紹這兩個模塊的具體功用和細節。app

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裏去。dom

業務邏輯

業務方面,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)範圍內隨機取值,由圓解析式
求解得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]範圍內隨機取值,而後使用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);
複製代碼

最終呈現效果以下:

明顯看到中心處的密集程度高於邊緣地帶,事實上當角度固定時,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);
複製代碼

最終呈現效果以下:

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

紅色是根函數,藍色是線性函數。二者相比下來,根函數的取值會更大些,相應的,接近邊緣的點就會更多一點,讓粒子的分佈效果更加均衡。

處理「奮筆疾書」狀況

當以比較慢的速度滑動時,筆畫尚顯流暢無明顯斷層。當速度過快時,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的思路、以及一些算法的解析。數學之美,使人沉醉*(數學學渣留下了悔恨的淚水。。。)*
數學纔是本體啊
筆刷項目地址在此,代碼中的註釋會更加清晰些,你們要是喜歡的話,不妨來點個贊吧

有歡迎關注個人公衆號,技術與生活

參考資料:

相關文章
相關標籤/搜索