编程实现三次bezier曲线的绘制_Vue + Canvas 实现流畅的钢笔涂鸦效果

疫情期间,小朋友们都采用远程教育的方式,其中有项涂鸦的功能,看着那不流畅的画笔,就有点怪难受的,就想着自己鼓捣一下。动手前,我曾考虑采用类似 Konva 这样的三方类库,但结合自身的需求,说不定后续会有更多的个性化需求呢,综合评定后还是选择了徒手撸 Canvas 来实现,要定制 Konva 等三方类库又麻烦,还是用 Canvas 从零开始吧。

准备开始前,我去体验了谷歌的涂鸦白板,Apache 的 OpenMeeting(功能齐全,UI 较为陈旧),以及小画桌,还有腾讯 / 网易的相关涂鸦白板。不得不说,谷歌的涂鸦白板,画出来的线条,真不是一般的顺滑,还附带了各种笔刷效果。想着自己要实现这样的功能了,虽然谷歌的涂鸦白板没有开源代码,但效果总有楷模了,还是可以尽量靠拢,多借鉴借鉴的。

f07a89446aa852e7b2b1ee8d218bd5ac.png

最终的实现效果

本文说的要实现钢笔效果,而在此之前,我已经实现了一种笔刷效果,即马克笔。该笔刷的实现较为简单些,具体实现可以看看《Canvas 实现流畅的画笔》

要实现钢笔的效果,我们得想想怎么模拟现实中钢笔写出来的效果,因为是针对 WEB 端的,力度方面无法获取,Pointer 拿到的力度值是不变的,我主要针对速度方面做了处理,其它方面未做处理。

对于速度的处理,我们可以尝试定义笔刷粗细在某个范围内,鼠标移动速度越快,笔刷就越细小,越慢就越粗。也就是说,我们可以记录坐标点生成的时间,比对两个坐标点的时间差来判定速度的快慢,再根据定义的粗细范围来确定笔刷粗细。这相对也符合我们写字时的状态,速度快,笔锋转,画出来的线条就细。虽然看起来不似毛笔那般复杂,提笔写的时候,各种中锋,侧锋,露锋等。话不多说,既然有了想法,我们来尝试实现一下简单的钢笔涂鸦效果。

准备绘制

鼠标按下,记录当前坐标点,生成临时画布(离屏渲染)并设置临时画布默认属性。

80eff6deca27cb34a0c8b26c6c885cdc.png

准备绘制

绘制过程

1. 调用 Point 基类,生成坐标

2. 获取记录数组中的最后一个坐标点数据

3. 计算两个坐标点之间的距离(如果距离过小 [ 我默认设置为5px ],则不进行绘制操作)

40af45806f15eec696c6502c3b6f4b0a.png

绘制过程

创建贝塞尔曲线实例

1. 记录坐标 points(保证该字段内有且仅有3个坐标点)。

2. 第1个坐标点,直接采用绘制原点操作,该方法内直接略过。

3. 因第1个坐标点已绘制成圆点,故第2个坐标点时,也直接略过。

4. 第3个坐标点进入,准备开始生成贝塞尔曲线类的实例。

5. 为了保证第1个坐标点与第2个坐标点的绘制连贯性,将强制插入第1个坐标点数据进 points 数组,新生成的数组,下标往后延1位

6. 最后将插入数据的清除即可。

e02e71bb1be0242614ee659a7e1850e1.png

创建贝塞尔曲线实例

计算笔刷粗细值

根据两坐标点之间的距离及其生成时记录的时间点,获取移动速度值,再根据该值来确定笔刷粗细值。

54aaf55941337b328586a3fe552260b7.png

计算笔刷粗细值

绘制基础方法

14154c12c2c3f1d10703ce1fc332183c.png

绘制曲线片段(圆)

绘制首个坐标圆点

66e7cefff653ed6397abc6d3c371745e.png

绘制首个坐标圆点

绘制曲线

根据贝塞尔曲线公式计算坐标点。

/** * 绘制曲线. * @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

根据贝塞尔公式计算坐标点

总结

至此,基本上实现了钢笔的功能,上面的几个步骤是主要的实现过程,最主要的就是要根据速度来计算笔刷粗细,而速度则可以根据两个坐标点形成之间的时间间隔来计算,自己定义一个基准数值来判定多少毫秒内算快,多长时间又算是慢的。接着再根据贝塞尔曲线公式将两个坐标点之间的距离,再细化成很多的坐标点,每个坐标点绘制成指定半径的圆形即可

虽然涂鸦的时候,很流畅了(毕竟是采用离屏渲染的),但我在尝试“拖拽”功能时,由于 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

节流控制类

坐标类 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;}}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值