压感笔书写效果有新基于OpenGL和动态大小端点的更高效的实现方法,可以移步: 进行了解
GL绘制自定义线条4_使用OpenGL ES实现钢笔效果-CSDN博客
本文用于在无法获取压感的设备上实现书法效果,因此所有的书写效果的笔触粗细变化,均是通过速率进行确认。最后实现效果如下(不会书法,只能让大家体会一下效果),代码基于之前的文章在安卓中结合使用GLSurfaceView和Canvas使用_笔记_可能有错_轮子初级玩家-CSDN博客 上进行修改而成:
一、推导:
在正常的情况下,使用Canvas和Path分段绘制用户的书写结果时,一般不会去改变Paint的大小,因此无论两次触摸事件点到点之间的长度多大——也就是速率多大,笔画都不会产生粗细变化。
那么如果我希望书写的时候,笔划的粗细可以随着速率的变化而变化的话,观察我们平常书写时笔划的粗细变化,那么我们可以定义如下规则:
1、速率快到一定程度,笔触成比例持续缩小,但设定一个最小比例值
2、速率慢到一定程度,笔触成比例持续增大,但设定一个最大比例值
3、速率介于上面两个阈值之间,笔触成比例增大或减小,逐渐重新接近比例值100%,即笔触原来大小。
这样,我们可以得到如下效果的线条:
示意图:
实际效果:
这样虽然有点接近,但是粗线条和细线条之间的粗细没有一个平滑的过渡,导致阶梯感非常严重。因此为了解决这个问题,还需要做一个细分功能。规则如下:
1、 设定一个分割量,例如30,即把两点之间的连线分成30份。
2、当前线条的笔触大小比例值和上个线条的笔触大小比例值之间的差值,也按照分割量细分,算出每份差值有多大。
3、线条按照分割量分成一个个小单元,计算两次小单元坐标之间的旋转程度,然后用一个个实心菱形或者其他笔触形状,按照旋转程度旋转,并按照差值累加比例值控制笔触大小渐变着地填充笔触形状到画布上,使得过度变得平滑。
该规则灵感来源于文章《基于iOS平台的实时手写美化技术及应用》4.2.2.3章节:
而关于Path如何根据Path某个微分段落的tan值进行旋转,可以参考这篇文章的“5.getPosTan”部分:
安卓自定义View进阶-Path之玩出花样(PathMeasure)_GcsSloop-CSDN博客_getsegment
实际效果:
可以看到笔尖形态和粗细过度效果好看很多了。
二、实际代码:
1、上次触摸点和本次触摸点之间绘制贝塞尔线段:
/**
* 落闸放点(狗),贝塞尔曲线化
*
* @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);
}
}
至此,就讲完了这个功能大致是怎么实现的了。