准备开始前,我去体验了谷歌的涂鸦白板,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);
}
绘制过程
-
调用 Point 基类,生成坐标
-
获取记录数组中的最后一个坐标点数据
-
计算两个坐标点之间的距离(如果距离过小 [ 我默认设置为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); } }
绘制过程
创建贝塞尔曲线实例
-
记录坐标 points(保证该字段内有且仅有3个坐标点)。
-
第1个坐标点,直接采用绘制原点操作,该方法内直接略过。
-
因第1个坐标点已绘制成圆点,故第2个坐标点时,也直接略过。
-
第3个坐标点进入,准备开始生成贝塞尔曲线类的实例。
-
为了保证第1个坐标点与第2个坐标点的绘制连贯性,将强制插入第1个坐标点数据进 points 数组,新生成的数组,下标往后延1位。
-
最后将插入数据的清除即可。
/** * 创建贝塞尔曲线对象 * @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;
}
}