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

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

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

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

准备绘制

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

/**
 * 准备绘制
 * @param event
 */
protected drawBegin(event: MouseEvent | PointerEvent | Touch): void {
    // 生成唯一UID,其他终端实时绘制时用
    this.cid = Utils.uid();
	this.syncData  = [];
	// 创建当前坐标点数据
	this.data.push(this.createPointGroup());
	// 生成临时画布(离屏渲染)
	this.buffer = this.createBuffer(this.getThickness(Tools.thickness, this.divisor));
	// 进入移动过程的事件处理
	this.drawUpdate(event);
}

绘制过程

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

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

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

    /**
     * 绘制过程
     * @param event
     */
    protected drawUpdate(event: MouseEvent | PointerEvent | Touch): void {
        /**
         * 1. 生成坐标(调用 Point 基类)
         * 2. 获取记录数组中的最后一个坐标点数据
         * 3. 计算两个坐标点之间的距离(如果距离过小[默认设置为5px],则不仅仅绘制)
         */
        const point = this.createPoint(event.clientX, event.clientY),
              lastPoints = this.getLastPoints(this.data),
              lastPointLen = lastPoints.length,
              lastPoint = lastPointLen > 0 && lastPoints[lastPointLen - 1]
              isPointsTooClose = lastPointLen ? point.distance(lastPoint) <= Tools.distance : false;
    	if (!lastPoint || !(lastPoint && isPointsTooClose)) {
            const curve = this.createCurve(point);
            if (!lastPoint) this.drawDot(point);
            else if (curve) this.drawCurve(curve);
            lastPoints.push(point);
        }
    }
    

绘制过程

创建贝塞尔曲线实例

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

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

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

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

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

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

    /**
     * 创建贝塞尔曲线对象
     * @param point
     */
    protected createCurve(point: Point): Bezier | null {
        // 记录坐标点
        this.curvePoint.points.push(point);
        // 第一个坐标点, 采用绘制圆点的方式处理,故需要3个点才能根据第2和第3个坐标点来生成二次贝塞尔曲线
        if (this.curvePoint.points.length < 3) return null;
    
        // 当且仅当只有3个坐标点时,为了保证第1个坐标点与第2个坐标点的连贯性,强制插入第一个坐标点的数据进数组
        if (this.curvePoint.points.length == 3) this.curvePoint.points.unshift(this.curvePoint.points[0]);
        // 根据垫高点坐标生成时间,计算画笔粗细
        const widths = this.calculateCurveWidths(this.curvePoint.points[1], this.curvePoint.points[2]);
        // 生成贝塞尔曲线实例
        const curve = Bezier.fromPoints(this.curvePoint.points, widths);
        // 移除刚刚插入的多余数据
        this.curvePoint.points.shift();
        return curve;
    }
    

计算笔刷粗细值

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

// 基础属性
velocity = .7;
width = {min: .5, max: 2.5};
protected curvePoint = {velocity: 0, width: 0, points:[]};

/**
 * 计算两点之间的曲线轨迹的画笔粗细(宽度)
 * @param startPoint 开始坐标
 * @param endPoint 结束坐标
 */
protected calculateCurveWidths(startPoint: Point, endPoint: Point): {start: number, end: number} {
    /**
     * 根据基础属性计算两个坐标点之间的距离,判定速度
     * velocityFrom 是 Point 基类的方法,后面附上 Point 基类代码
     */
    const velocity = this.velocity * endPoint.velocityFrom(startPoint) + (1 - this.velocity) * this.curvePoint.velocity,
          width = this.getTrailWitdh(velocity),
          widths = {start: thhis.curvePoint.width, end: width};
    // 记录属性
    this.curvePoint.width = width;
    this.curvePoint.velocity = velocity;
    return widths;
}

/**
 * 画笔绘制轨迹的宽度(速度越快,越细)
 * @param velocity 速度
 * @return number
 */
protected getTrailWidth(velocity: number): number {
    return Math.max(this.width.max / (velocity + 1)), this.width.min);
}

绘制基础方法

/**
 * 绘制曲线段(园).
 * @param ctx
 * @param x
 * @param y
 * @param width
 */
protected drawCurveSegment(ctx: CanvasRenderingContext2D, x: number, y: number, width: number): void {
    ctx.moveTo(x, y);
	ctx.arc(x, y, width, 0, 2 * Math.PI, false);
}

绘制首个坐标圆点

/**
 * 绘制圆点.
 * @param point
 * @param ctx
 */
protected drawDot(point: Point, ctx?: CanvasRenderingContext2D) :void {
    if (!ctx) ctx = this.getBufferContext();
    const width = (this.width.min + this.width.max) / 2;
	ctx.beginPath();
	this.drawCurveSegment(ctx, point.x, point.y, width);
	ctx.closePath();
	ctx.fill();
}

绘制曲线

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

/**
 * 绘制曲线. 
 * @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 as number;
        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 + tt * delta, this.width.max);
        this.drawCurveSegment(ctx, x, y, width)
    }
	ctx.closePath();
	ctx.fill();
}

总结

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

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

export function throttle(fn: (...args: any[]) => any, wait = 1000): any {
    let previous = 0;
    let timeout: number | null = null;
    let result: any;
    let storedContext: any;
    let storedArgs: any[];
    const later = () => {
        previous = Date.now();
        timeout = null;
        result = fn.apply(storedContext, storedArgs);
        if (!timeout) {
            storedContext = null;
            storedArgs = [];
        }
    };
    return function wrapper(this: any, ...args: any[]) {
        const now = Date.now();
        const remaining = wait - (now - previous);
        storedContext = this;
        storedArgs = args;
        if (remaining <= 0 || remaining > wait) {
            if (timeout) {
                clearTimeout(timeout);
                timeout = null;
            }
            previous = now;
            result = fn.apply(storedContext, storedArgs);
            if (!timeout) {
                storedContext = null;
                storedArgs = [];
            }
        } else if (!timeout) {
            timeout = setTimeout(later, remainning);
        }
        return result;
    }
}

坐标类 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;
    }
}