仿Windows画板喷漆笔刷效果

2,704 阅读8分钟

基于Java代码实现,并附有相应的Kotlin版本
原创文章,转载请联系作者

软草平莎过雨新,轻沙走马路无尘。
何时收拾耦耕身?

先上效果图:

笔刷项目地址在此,大家要是喜欢的话,不妨来点个赞吧

效果解析

因为最终要实现的是windwos下的画板喷漆笔刷,所以首先要对它做一个较为详细的效果解析。考虑到笔一般情况下笔刷的使用点,故此会分析一下线的效果细节。

  • 画点

从左至右依次是对同一坐标点击2次,点击8次,点击16次的效果展示;
当数量趋向更大时,点的密集程度并没有很明显的偏向,基本可以确定要在圆内均匀分布

  • 画线

如图为匀速且缓慢滑过时,由点构成线

具体实现

项目的大致框架由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)范围内随机取值,由圆解析式
求解得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的思路、以及一些算法的解析。数学之美,令人沉醉*(数学学渣留下了悔恨的泪水。。。)*
数学才是本体啊
笔刷项目地址在此,代码中的注释会更加清晰些,大家要是喜欢的话,不妨来点个赞吧

有欢迎关注我的公众号,技术与生活

参考资料: