疫情期间,小朋友们都采用远程教育的方式,其中有项涂鸦的功能,看着那不流畅的画笔,就有点怪难受的,就想着自己鼓捣一下。动手前,我曾考虑采用类似 Konva 这样的三方类库,但结合自身的需求,说不定后续会有更多的个性化需求呢,综合评定后还是选择了徒手撸 Canvas 来实现,要定制 Konva 等三方类库又麻烦,还是用 Canvas 从零开始吧。
准备开始前,我去体验了谷歌的涂鸦白板,Apache 的 OpenMeeting(功能齐全,UI 较为陈旧),以及小画桌,还有腾讯 / 网易的相关涂鸦白板。不得不说,谷歌的涂鸦白板,画出来的线条,真不是一般的顺滑,还附带了各种笔刷效果。想着自己要实现这样的功能了,虽然谷歌的涂鸦白板没有开源代码,但效果总有楷模了,还是可以尽量靠拢,多借鉴借鉴的。
![f07a89446aa852e7b2b1ee8d218bd5ac.png](https://i-blog.csdnimg.cn/blog_migrate/f138a3b5f370e48483f1bc33778aa73f.jpeg)
最终的实现效果
本文说的要实现钢笔效果,而在此之前,我已经实现了一种笔刷效果,即马克笔。该笔刷的实现较为简单些,具体实现可以看看《Canvas 实现流畅的画笔》
要实现钢笔的效果,我们得想想怎么模拟现实中钢笔写出来的效果,因为是针对 WEB 端的,力度方面无法获取,Pointer 拿到的力度值是不变的,我主要针对速度方面做了处理,其它方面未做处理。
对于速度的处理,我们可以尝试定义笔刷粗细在某个范围内,鼠标移动速度越快,笔刷就越细小,越慢就越粗。也就是说,我们可以记录坐标点生成的时间,比对两个坐标点的时间差来判定速度的快慢,再根据定义的粗细范围来确定笔刷粗细。这相对也符合我们写字时的状态,速度快,笔锋转,画出来的线条就细。虽然看起来不似毛笔那般复杂,提笔写的时候,各种中锋,侧锋,露锋等。话不多说,既然有了想法,我们来尝试实现一下简单的钢笔涂鸦效果。
准备绘制
鼠标按下,记录当前坐标点,生成临时画布(离屏渲染)并设置临时画布默认属性。
![80eff6deca27cb34a0c8b26c6c885cdc.png](https://i-blog.csdnimg.cn/blog_migrate/df234d133a4647288e3382d1ec8d46ab.jpeg)
准备绘制
绘制过程
1. 调用 Point 基类,生成坐标
2. 获取记录数组中的最后一个坐标点数据
3. 计算两个坐标点之间的距离(如果距离过小 [ 我默认设置为5px ],则不进行绘制操作)
![40af45806f15eec696c6502c3b6f4b0a.png](https://i-blog.csdnimg.cn/blog_migrate/875a90c9f3d81e01309dabb845dc69c9.jpeg)
绘制过程
创建贝塞尔曲线实例
1. 记录坐标 points(保证该字段内有且仅有3个坐标点)。
2. 第1个坐标点,直接采用绘制原点操作,该方法内直接略过。
3. 因第1个坐标点已绘制成圆点,故第2个坐标点时,也直接略过。
4. 第3个坐标点进入,准备开始生成贝塞尔曲线类的实例。
5. 为了保证第1个坐标点与第2个坐标点的绘制连贯性,将强制插入第1个坐标点数据进 points 数组,新生成的数组,下标往后延1位。
6. 最后将插入数据的清除即可。
![e02e71bb1be0242614ee659a7e1850e1.png](https://i-blog.csdnimg.cn/blog_migrate/27ce6242d0b7b6812d66eaf3368fa58c.jpeg)
创建贝塞尔曲线实例
计算笔刷粗细值
根据两坐标点之间的距离及其生成时记录的时间点,获取移动速度值,再根据该值来确定笔刷粗细值。
![54aaf55941337b328586a3fe552260b7.png](https://i-blog.csdnimg.cn/blog_migrate/49a1f83a928967f9a4928e0c2d528a70.jpeg)
计算笔刷粗细值
绘制基础方法
![14154c12c2c3f1d10703ce1fc332183c.png](https://i-blog.csdnimg.cn/blog_migrate/4198849dac4cb933b73cb3f4964e6665.jpeg)
绘制曲线片段(圆)
绘制首个坐标圆点
![66e7cefff653ed6397abc6d3c371745e.png](https://i-blog.csdnimg.cn/blog_migrate/e28cd41abd01cd77f2c61e0f153f0f17.jpeg)
绘制首个坐标圆点
绘制曲线
根据贝塞尔曲线公式计算坐标点。
/** * 绘制曲线. * @param curve * @param ctx */protected drawCurve( curve: Bezier, ctx?: CanvasRenderingContext2D): void { if (!ctx) ctx = this.buffer.getContext(); const delta = curve.endWidth - curve.startWidth, steps = Math.floor(curve.length()) * 2; ctx.beginPath(); /** 根据公式循环计算曲线坐标点 */ for (let i = 0; i < steps; i += 1) { const t = i / steps, tt = t * t, ttt = tt * t; const u = 1- t, uu = u * u, uuu = uu * u; let x = uuu * curve.startPoint.x as number; x += 3 * uu * t * curve.control1.x; x += 3 * u * tt * curve.control2.x; x += ttt * curve.endPoint.x; let y = uuu * curve.startPoint.y; y += 3 * uu * t * curve.control1.y; y += 3 * u * tt * curve.control2.y; y += ttt * curve.endPoint.y; const width = Math.min(curve.startWidth + ttt * delta, this.width.max); this.drawCurveSegment(ctx, x, y, width); } ctx.closePath();}
![6e63acfbe4492999d5a91c3a754ec04d.png](https://i-blog.csdnimg.cn/blog_migrate/9b10a3853e5240f26507cf9a33e92667.jpeg)
根据贝塞尔公式计算坐标点
总结
至此,基本上实现了钢笔的功能,上面的几个步骤是主要的实现过程,最主要的就是要根据速度来计算笔刷粗细,而速度则可以根据两个坐标点形成之间的时间间隔来计算,自己定义一个基准数值来判定多少毫秒内算快,多长时间又算是慢的。接着再根据贝塞尔曲线公式将两个坐标点之间的距离,再细化成很多的坐标点,每个坐标点绘制成指定半径的圆形即可。
虽然涂鸦的时候,很流畅了(毕竟是采用离屏渲染的),但我在尝试“拖拽”功能时,由于 Canvas 是不支持事件绑定的,拖拽过程中需要不断的重新绘制,涂鸦内容少的情况下,看不出来有什么影响,但如果涂鸦内容很多,就会导致拖拽过程中,出现闪烁的情况,在 60HZ 的显示器下,16ms 内根本刷不过来,主要问题出现在两个坐标点之间,根据贝塞尔曲线公式继续细化坐标点,计算量有点大。回头我再尝试优化下,整理后将代码托管至自己搭建的 Gogs 平台上去,有需要的小伙伴们可以下载下来试试。
最最后再附上几个基础类,代码量有点多,有些是精简过的。
贝塞尔曲线类 Bezier
import {MiPoint, Point} from '@components/canvas/Point';export default class Bezier {constructor(public startPoint: Point,public endPoint: Point,public control1: MiPoint,public control2: MiPoint,public startWidth: number,public endWidth: number) {}/** * 根据坐标点返回贝塞尔曲线的对象. * @param points {Point} * @param width */public static fromPoints(points: Point[],width: {start: number;end: number;}): Bezier {const c1 = this.calculateControlPoint(points[1], points[2], points[3]).c1,c2 = this.calculateControlPoint(points[0], points[1], points[2]).c2;return new Bezier(points[1], points[2], c1, c2, width.start, width.end);}/** * 计算三次贝塞尔曲线的控制点. * 可以将三次贝塞尔看成由2段二次贝塞尔曲线组成的来计算. * @param p1 {MiPoint} * @param p2 {MiPoint} * @param p3 {MiPoint} */private static calculateControlPoint(p1: MiPoint,p2: MiPoint,p3: MiPoint): {c1: MiPoint;c2: MiPoint;} {/** 坐标点之间的距离差(p1 与 p2 / p2 与 p3) */const dx1 = p1.x - p2.x,dy1 = p1.y - p2.y,dx2 = p2.x - p3.x,dy2 = p2.y - p3.y;/** 两点之间的中点坐标 */const mp1 = {x: (p1.x + p2.x) / 2.0,y: (p1.y + p2.y) / 2.0}, mp2 = {x: (p2.x + p3.x) / 2.0,y: (p2.y + p3.y) / 2.0};/** 直线长度 */const l1 = Math.sqrt(dx1 * dx1 + dy1 * dy1),l2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);/** 中间点之间的坐标距离差 */const dxm = mp1.x - mp2.x,dym = mp1.y - mp2.y;/** 长度比例(确定平移前的位置) */const r = l2 / (l1 + l2);/** 平移 */const tm = {x: mp2.x + dxm * r,y: mp2.y + dym * r};const tx = p2.x - tm.x,ty = p2.y - tm.y;return {c1: new Point(mp1.x + tx, mp1.y + ty),c2: new Point(mp2.x + tx, mp2.y +ty)};}/** * 计算贝塞尔曲线(直线)长度. * 曲线是直线的充分必要条件是所有的控制点都在曲线上. * 同样, 贝塞尔曲线是直线的充分必要条件是控制点共线. * 所以: 上一个点与下一个点坐标之间的直线距离, 不断累加即为曲线长度. * @return number * @see point */public length(): number {const steps = 10;let length = 0, px!: number, py!: number;for (let i = 0; i <= steps; i++) {const t = i / steps;const cx = this.point(t,this.startPoint.x,this.endPoint.x,this.control1.x,this.control2.x);const cy = this.point(t,this.startPoint.y,this.endPoint.y,this.control1.y,this.control2.y);if (i > 0) {const dx = cx - px,dy = cy - py;length += Math.sqrt(dx * dx + dy * dy);}px = cx;py = cy;}return length;}/** * 三次贝塞尔曲线的路径计算公式(点插值): * B(t) = ((1-t)^3 * p0) * + (3 * (1-t)^2 * t * p1) * + (3 * (1-t) * t^2 * p2) * + (t^3 * p3) * t 的取值范围为 [0, 1] 之间 * @param t 辅助值 * @param start 起始点 * @param end 结束点 * @param c1 控制点1 * @param c2 控制点2 */protected point(t: number,start: number,end: number,c1: number,c2: number): number {return (start * (1.0 - t) * (1.0 - t) * (1.0 - t))+ (3.0 * c1 * (1.0 - t) * (1.0 -t) * t)+ (3.0 * c2 * (1.0 - t) * t * t)+ (end * t * t * t);}}
节流控制 Throttle
![5ad69042dbad7c65140220f5ffff02fb.png](https://i-blog.csdnimg.cn/blog_migrate/28daaf361bf1b28440eacad90afb6f26.jpeg)
节流控制类
坐标类 Point
export interface MiPoint {x: number;y: number;time: number;}export class Point implements MiPoint {public x: number;public y: number;public time: number;constructor(x: number, y: number, time?: number) {this.x = x;this.y = y;this.time = time || Date.now();}/** * 两点直线距离. * @param start */public distanceTo(start: MiPoint): number {return Math.sqrt(Math.pow(this.x - start.x, 2)+ Math.pow(this.y - start.y, 2));}/** * 判断相等. * @param point */public equals(point: MiPoint): boolean {return this.x === point.x&& this.y === point.y&& this.time === point.time;}/** * 划线的速度(时间差). * @param start */public velocityFrom(start: MiPoint): number {return this.time !== start.time? this.distanceTo(start) / (this.time - start.time): 0;}}