在 Canvas 中对基于压感的笔迹进行优化

对笔迹进行优化:实现平滑的曲线

在编写咕茶绘的绘板部分的时候,最初使用的是很常见的使用 PointerEvent 接口中所提供的坐标和压感,直接使用 moveTo() 方法在 Canvas 上的点与点之间绘制直线连接成一条线,由于 PointerEvent 接口对笔迹的扫描频率问题,线条会出现较为明显的不协调感。

image-20220322021030529

这种问题在快速划线的时候尤其显著。

image-20220322021047832

好在 HTML5 的 Canvas 元素提供了另外一个接口,让我们可以使用二次贝塞尔曲线的方法来绘制平滑的曲线,这种方法只需我们计算出一个控制点即可为起始点和结束点之间绘制平滑的曲线。这里的方案是,当点大于 3 个的时候,每读取到一个新的点,就将其和起始点的中间值作为新的结束点,上一个点作为控制点来进行绘制,并在绘制完毕之后将结束点作为新的起始点供下一次绘制使用,这样做即可获得了一个比较好的线段平滑效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
interface pointData {
x: number,
y: number,
pressure: number,
}

const canvasMove = (e:PointerEvent) => {
if (data.movable && e.pointerId == data.pointerId) {
let x = e.offsetX;
let y = e.offsetY;
let pressure = e.pressure;
points.push({x, y, pressure}); // 存入 points 数组以用来绘制平滑的曲线
// 对于压感差距不大的正常情况
const controlPoint = lastTwoPoints[0];
const endPoint:pointData = {
x: (lastTwoPoints[0].x + lastTwoPoints[1].x) / 2,
y: (lastTwoPoints[0].y + lastTwoPoints[1].y) / 2,
pressure: lastTwoPoints[1].pressure
}
// 原本 data.ctx.moveTo(); 所在的位置
canvasDrawLine(beginPoint, controlPoint, endPoint);
beginPoint = endPoint;
}
};

基于以上原理,我们对原本简单的 moveTo() 部分进行改进。

1
2
3
4
5
6
7
8
9
const canvasDrawLine = (beginPoint:pointData, controlPoint:pointData, endPoint:pointData) => {
// 通过设置的线宽与当前压力值的平方相乘来计算实际需要绘制的线宽
data.ctx.lineWidth = endPoint.pressure * endPoint.pressure * data.lineWidth;
data.ctx.beginPath();
data.ctx.moveTo(beginPoint.x, beginPoint.y);
data.ctx.quadraticCurveTo(controlPoint.x, controlPoint.y, endPoint.x, endPoint.y);
data.ctx.stroke();
data.ctx.closePath();
};

现在,我们的笔迹是这样的,是不是圆润英俊了许多!

image-20220322102133008

对笔锋进行优化:平滑起笔收笔

线条的平滑度搞定之后,我们很快就能发现另外一个问题:依然是扫描频率过低的锅,当使用支持压感的设备快速划线时,由于两个点之间压感差距过大会产生起笔和收笔时线条宽度不连续的问题。

img

对于这种取样点之间压感差距过大的情况,我们可以采用上述的方法来人工计算两个点之间压感与距离的插值,继而拟合出一个 “看起来很像样子” 的起笔与收笔。

依然是采用贝塞尔曲线的方法绘制,我们最终完成的 canvasMove() 函数如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
const canvasMove = (e:PointerEvent) => {
if (data.movable && e.pointerId == data.pointerId) {
let x = e.offsetX;
let y = e.offsetY;
let pressure = e.pressure;
points.push({x, y, pressure}); // 存入 points 数组以用来绘制平滑的曲线
if(points.length > 3) {
const lastTwoPoints = points.slice(-2);
if(lastTwoPoints[0].pressure - lastTwoPoints[1].pressure > 0.1) { // 优化笔锋
// 当两个点压感之间差距约 0.02 的时候会得到一个比较完美的笔锋效果
let pieces = Math.floor((lastTwoPoints[0].pressure - lastTwoPoints[1].pressure) / 0.02);
let partX = (lastTwoPoints[1].x - lastTwoPoints[0].x) / pieces;
let partY = (lastTwoPoints[1].y - lastTwoPoints[0].y) / pieces;
let partP = (lastTwoPoints[0].pressure - lastTwoPoints[1].pressure) / pieces;
for(let i = 0 ;i < Math.floor(pieces); i+=2){
const controlPoint = {
x: lastTwoPoints[0].x + i * partX,
y: lastTwoPoints[0].y + i * partY,
pressure: lastTwoPoints[0].pressure - i * partP
};
const endPoint = {
x: lastTwoPoints[0].x + (i + 1) * partX,
y: lastTwoPoints[0].y + (i + 1) * partY,
pressure: lastTwoPoints[0].pressure - (1 + i) * partP
};
canvasDrawLine(beginPoint, controlPoint, endPoint);
beginPoint = endPoint;
}
} else { // 对于压感差距不大的正常情况
const controlPoint = lastTwoPoints[0];
const endPoint = {
x: (lastTwoPoints[0].x + lastTwoPoints[1].x) / 2,
y: (lastTwoPoints[0].y + lastTwoPoints[1].y) / 2,
pressure: lastTwoPoints[1].pressure
}
canvasDrawLine(beginPoint, controlPoint, endPoint);
beginPoint = endPoint;
}
}
}
};

现在来试一下!

image-20220322102455699

参考文章

  1. 原笔迹手写实现平滑和笔锋效果之:笔迹的平滑(一)