使用Canvas + Path和“曲面细分”算法实现钢笔笔锋效果

本文用于在没法获取压感的设备上实现书法效果,所以全部的书写效果的笔触粗细变化,均是经过速率进行确认。最后实现效果以下(不会书法,只能让你们体会一下效果),代码基于以前的文章https://blog.csdn.net/cjzjolly/article/details/88661286 上进行修改而成:html

1、推导java

      在正常的状况下,使用Canvas和Path分段绘制用户的书写结果时,通常不会去改变Paint的大小,所以不管两次触摸事件点到点之间的长度多大——也就是速率多大,笔画都不会产生粗细变化。canvas

       那么若是我但愿书写的时候,笔划的粗细能够随着速率的变化而变化的话,观察咱们日常书写时笔划的粗细变化,那么咱们能够定义以下规则:工具

        一、速率快到必定程度,笔触成比例持续缩小,但设定一个最小比例值post

        二、速率慢到必定程度,笔触成比例持续增大,但设定一个最大比例值ui

        三、速率介于上面两个阈值之间,笔触成比例增大或减少,逐渐从新接近比例值100%,即笔触原来大小。.net

这样,咱们能够获得以下效果的线条:code

       示意图:orm

        

        实际效果:htm

        这样虽然有点接近,可是粗线条和细线条之间的粗细没有一个平滑的过渡,致使阶梯感很是严重。所以为了解决这个问题,还须要作一个细分功能。规则以下:

        一、 设定一个分割量,例如30,即把两点之间的连线分红30份。

        二、当前线条的笔触大小比例值和上个线条的笔触大小比例值之间的差值,也按照分割量细分,算出每份差值有多大。

        三、线条按照分割量分红一个个小单元,计算两次小单元坐标之间的旋转程度,而后用一个个实心菱形或者其余笔触形状,按照旋转程度旋转,并按照差值累加比例值控制笔触大小渐变着地填充笔触形状到画布上,使得过分变得平滑。

       该规则灵感来源于文章《基于iOS平台的实时手写美化技术及应用》4.2.2.3章节:

       http://www.doc88.com/p-1012869503723.html

       而关于Path如何根据Path某个微分段落的tan值进行旋转,能够参考这篇文章的“5.getPosTan”部分:

        https://blog.csdn.net/u013831257/article/details/51565591

 

实际效果:

能够看到笔尖形态和粗细过分效果好看不少了。

 

2、实际代码:

一、上次触摸点和本次触摸点之间绘制贝塞尔线段:

/**
     * 落闸放点(狗),贝塞尔曲线化
     *
     * @param x
     * @param y
     * @param action
     */
    public void setCurrent(float x, float y, int action) {
        if(!isStart()) {
            setCurrentRaw(x, y, action);

            totalPath.moveTo(x, y);
//            if(!isBuildPathAllDoing)
            touchPointList.add(new PointF(x, y));
            segPathList.add(new Path());
        } else {
            if (action == MotionEvent.ACTION_UP)
                System.out.println("setCurrent end " + x + " , " + y);
            touchPointList.add(new PointF(x, y));
            drawPath = new Path();
            segPathList.add(drawPath);
            setCurrentRaw(x, y, action);

            double distance = Math.sqrt(Math.pow(Math.abs(x - last.x), 2) + Math.pow(Math.abs(y - last.y), 2));
            /**若是两次点击之间的距离过大,就判断为该点报废,Current点回退到last点**/
            if (distance > 400) {  //若是距离突变过长,判断为无效点,直接current回退到上一次纪录的last的点,而且用UP时间结束此次path draw
                Log.i("NewCurv.SetCurrent", "超长" + distance);
//                super.setCurrent(getLast().x, getLast().y, MotionEvent.ACTION_UP);
                System.out.println("超长?");
                return;
            }
            cx = last.x;
            cy = last.y;

            midX = (x + cx) / 2;
            midY = (y + cy) / 2;

            startX = mid.x;
            startY = mid.y;

            mid.x = midX;
            mid.y = midY;

            drawPath.moveTo(startX, startY);

            double s = Math.sqrt(Math.pow(x - cx, 2) + Math.pow(y - cy, 2));
            if (action == MotionEvent.ACTION_UP){
                drawPath.lineTo(x,y);
                totalPath.lineTo(x, y);
            } else {
                if (s < 200) {
                    if (s < 10) {//1.10 //2.12 //3.15
                        drawPath.cubicTo(cx, cy, midX, midY, x, y);
                        totalPath.cubicTo(cx, cy, midX, midY, x, y);
                        System.out.println("cubicTo");
                    } else {
                        drawPath.quadTo(cx, cy, midX, midY);
                        totalPath.quadTo(cx, cy, midX, midY);
//                    System.out.println("quadTo");
                    }
                } else {
                    drawPath.quadTo(cx, cy, midX, midY);
                    totalPath.quadTo(cx, cy, midX, midY);
                }
            }
        }
        //抬起时把画好的线段生成OpenGL线段
//        if(action == MotionEvent.ACTION_UP) {
//            //OpenGL此时DPI和Canvas不同,要放大再对景区
//            Path path = new Path();
//            Matrix matrix = new Matrix();
//            matrix.postScale(UITrees.openGLRenderer.scale / 2, UITrees.openGLRenderer.scale / 2, UITrees.panelView.scaleCenterPoint.x, UITrees.panelView.scaleCenterPoint.y);
//            totalPath.transform(matrix, path);
//
//            PathMeasure pathMeasure = new PathMeasure();
//            pathMeasure.setPath(path, false);
//            float step = 10f / paint.getStrokeWidth() > 1 ? 10f / paint.getStrokeWidth() : 1; //粗线条的点密度设置大一些咯
//
//            float[] point = new float[2];
//            for(float i = 0; i < pathMeasure.getLength(); i += step) {
//                pathMeasure.getPosTan(i, point, null);
//                //todo 缩放以后,Canvas再加Path的时候仍是采用实际点,但OpenGL用了这个点就和Canvas的不对齐了,由于OpenGL缩放是把画布先后推,要作作换算,例如缩放小了,左上角的坐标是画布外的坐标
//
//                float realtiveX = point[0] / 1080 * 4f - UITrees.openGLRenderer.dx;  //4个象限
//                float realtiveY = -point[1] / 1080 * 4f + UITrees.openGLRenderer.dy ;
//
//                glLine.drawLine(realtiveX, realtiveY);
//            }
//        }
        if(action == MotionEvent.ACTION_UP) {
            //OpenGL此时DPI和Canvas不同,要放大再对景区
            Path path = new Path();
            Matrix matrix = new Matrix();
            //即便缩小事后,OpenGL画布实际上仍是原来的大小,只是由于透视原理推远了看起来才小了,因此必须以屏幕中心为缩放中心放大图像以后再贴在OpenGL画布,并根据OpenGL的偏移值偏移图像顶点,看起来才会和Canvas的同样大:
            matrix.postScale(1 / UITrees.panelView.totalScale, 1 / UITrees.panelView.totalScale, 1920 / 2, 1080 / 2);
            totalPath.transform(matrix, path);
            //缩放过程当中缩放中心不定,所以如何很好地进行贴在OpenGL呢?
            PathMeasure pathMeasure = new PathMeasure();
            pathMeasure.setPath(path, false);
            float step = 10f / paint.getStrokeWidth() > 1 ? 10f / paint.getStrokeWidth() : 1; //粗线条的点密度设置大一些咯

            float[] point = new float[2];
            for(float i = 0; i < pathMeasure.getLength(); i += step) {
                pathMeasure.getPosTan(i, point, null);
                //写进OpenGL的点还要作点偏移才能对正
                float realtiveX = point[0] / 1080 * 4f - UITrees.openGLRenderer.dx;  //
                float realtiveY = -point[1] / 1080 * 4f + UITrees.openGLRenderer.dy ;
                glLine.drawLine(realtiveX, realtiveY);
            }
        }
    }

        在生成了本次的贝塞尔曲线以后,即可以根据此次曲线的长度为依据来判断速率的快慢了,从而肯定笔触应该按照多大的比例进行放大或者缩小:

public void drawTo(Canvas canvas) {
        Paint changedPaint = new Paint(paint);
        changedPaint.setStrokeCap(Paint.Cap.ROUND);//结束的笔画为圆心
        changedPaint.setStrokeJoin(Paint.Join.ROUND);//链接处元
        changedPaint.setStrokeMiter(1.0f);

        if(segPathList.size() >= 2) {
            Path prevPath = segPathList.get(segPathList.size() - 2);
            PathMeasure pathMeasure = new PathMeasure(prevPath, false);
            float beforeRatio = ratio; //从以前的曲率渐变到如今的曲率,产生细分效果

            if (paint.getStrokeWidth() > 5) { //粗线条的粗细率变化
                if (pathMeasure.getLength() > 15f) { //速度快
                    if (ratio > 0.3f) {
                        ratio *= 0.8f;
                    }
                } else if (pathMeasure.getLength() < 5f) { //速度慢
                    if (ratio < 1.5f) {
                        ratio *= 1.15f;
                    }
                } else { //不快不慢,回到原大小
                    if (ratio > 1f) {
                        ratio *= 0.95f;
                    } else {
                        ratio *= 1.15f;
                    }
                }
            } else { //细线条的粗细率变化
                if (pathMeasure.getLength() > 5f) { //速度快
                    if (ratio > 0.3f) {
                        ratio *= 0.5f;
                    }
                } else if (pathMeasure.getLength() < 2f) { //速度慢
                    if (ratio < 1.5f) {
                        ratio *= 1.4f;
                    }
                } else { //不快不慢,回到原大小
                    if (ratio > 1f) {
                        ratio *= 0.8f;
                    } else {
                        ratio *= 1.2f;
                    }
                }
            }
            changedPaint.setStrokeWidth(changedPaint.getStrokeWidth() * ratio);
            //分母,将当成小线段drawPath细分红多少份,份数越大曲面细分越细腻
            float denominator = 60f;
            Paint smallPaint = new Paint(paint);
            smallPaint.setStrokeWidth(2f);
            smallPaint.setStyle(Paint.Style.FILL);
            smallPaint.setMaskFilter(new BlurMaskFilter(0.8f, BlurMaskFilter.Blur.SOLID));
            //todo j起始值过小会有毛刺,太大会显得不连贯
            /** 按百分比遍历的循环
             *  i ---> 粗细比率的遍历用变量
             *  j ---> 遍历细分的PathMeasure的分子的存储变量
             *  denominator   ----->   分母,将当成小线段drawPath细分红多少份
             * (ratio - beforeRatio) / denominator ----> 将百分率的渐变细分红像线条那么多份,而后每份有多大
             *
             * **/
            if(beforeRatio < ratio){
                for(float i = beforeRatio, j = 0.4f; i < ratio && j < denominator; i += (ratio - beforeRatio) / denominator, j++){
                    drawCurvSubDivision(paint, smallPaint, pathMeasure, canvas, i, j, denominator);
                }
            } else {
                for(float i = beforeRatio, j = 0.4f; i >= ratio && j < denominator; i += (ratio - beforeRatio) / denominator, j++){
                    drawCurvSubDivision(paint, smallPaint, pathMeasure, canvas, i, j, denominator);
                }
            }
            DrawPathAndPaint drawPathAndPaint = new DrawPathAndPaint();
            drawPathAndPaint.inLength = new PathMeasure(totalPath, false).getLength() - new PathMeasure(drawPath, false).getLength();
            drawPathAndPaint.paint = changedPaint;
            drawPathAndPaint.ratio = ratio;
            drawPathAndPaintList.add(drawPathAndPaint);
        }
    }

   而后,就是细分贝塞尔曲线Path,并使用PathMeasure工具沿着Path轨迹,按照以前提到的细分规则绘制菱形:

    其中

        pathArrow.moveTo(0, w / 2);
        pathArrow.lineTo(w, 0 );
        pathArrow.lineTo(2 * w, w / 2);
        pathArrow.lineTo(w, w);
        pathArrow.lineTo(0, w / 2);

是用来绘制长宽比例2:1的扁菱形的,参数图示以下,w是画笔的宽度,能够根据传入画笔的宽度调整菱形单元的大小,无数细密的菱形便可组成相似钢笔效果的线条:

 

/** 传入当前绘制线段的PathMeasure,用菱形曲面细分使得笔划细腻而好看,避免粗细的突变感
     *  @param  paint  传入原始画笔,用于经过画笔宽度肯定菱形长度和高度
     *  @param  borderPaint 传入菱形的边界画笔,用于肯定每一个菱形单元用多粗的线条进行绘制
     *  @param  drawPathMeasure 须要被曲面细分的线条的PathMeasure,用于遍历该线条
     *  @param  divisionRatio 传入PathMeausre指定目标刻度应该用多少本来画笔粗细的比率来绘制一个菱形,来造成渐变过分
     *  @param  pathMeausreNumerator 传入要进行细分绘制的PathMeasure的第几个刻度(用分子表示)
     *  @param  pathMeasureDenominator  传入要进行细分绘制的PathMeasure分红了几份,即分母,分母越大,则细分分数越多,线条则越细腻
     * **/
    private void drawCurvSubDivision(Paint paint, Paint borderPaint, PathMeasure drawPathMeasure, Canvas canvas, float divisionRatio, float pathMeausreNumerator, float pathMeasureDenominator){
        Path pathArrow = new Path();
        float w = paint.getStrokeWidth() * divisionRatio > 1f ? paint.getStrokeWidth() * divisionRatio : 1f;
        pathArrow.moveTo(0, w / 2);
        pathArrow.lineTo(w, 0 );
        pathArrow.lineTo(2 * w, w / 2);
        pathArrow.lineTo(w, w);
        pathArrow.lineTo(0, w / 2);
        float[] pos = new float[2];
        float[] tan = new float[2];
        drawPathMeasure.getPosTan(pathMeausreNumerator / pathMeasureDenominator * drawPathMeasure.getLength(), pos, tan);
//                        canvas.drawCircle(pos[0], pos[1], paint.getStrokeWidth() / 2f * smallRatio, changedPaint);
        Matrix matrix = new Matrix();
        //计算方位角
        float degrees = (float)(Math.atan2(tan[1], tan[0]) * 180.0 / Math.PI);
        RectF rect = new RectF();
        pathArrow.computeBounds(rect, false);
        matrix.postRotate(degrees, rect.width() / 2, rect.height() / 2);   // 旋转图片
        matrix.postTranslate(pos[0] - rect.width() / 2, pos[1] - rect.height() / 2);   // 将图片绘制中心调整到与当前点重合
        pathArrow.transform(matrix);
        if(canvas != null){
            canvas.drawPath(pathArrow, borderPaint);
        }
    }

至此,就讲完了这个功能大体是怎么实现的了。