我们的项目是面向学校老师的教学软件,所以肯定少不了互动白板的功能,而这个里面的画笔功能是由我来开发的,下面介绍这个过程中遇到的问题以及解决方法。
首先给大家明确下由于软件中的画布可以*移动,会超出屏幕显示范围,同时支持点擦和线擦,所以需要存储所有点坐标。
第一版简单画笔实现并优化掉折线感
第一版实现的肯定是很简单的画笔线条,由给定的鼠标坐标位置连线画出线段,主要使用的canvas的API方法有:beginPath moveTo lineTo stroke。不过很快发现当鼠标快速画曲线时出现很明显的拐点,这里要用到贝塞尔曲线来解决,具体可参考《利用贝塞尔绘制平滑曲线》。
第二版解决快速画线时笔迹跟不上鼠标移动的问题
实现了贝塞尔曲线的绘制,同时也产生新的问题,绘制过程中会出现线条的延长跟不上鼠标的情况(这是由于贝塞尔曲线的应用引起的,二次贝塞尔曲线绘制的时候需要三点确定起始点和控制点,《利用贝塞尔绘制平滑曲线》有具体讲解,看懂就能明白为什么会跟不上了)。
由于我们存储了所有点坐标,所以解决这个题也好办,就是mousemove触发绘制时都遍历一遍本条线上所有点来绘制这条线。
所以每次鼠标移动采用的绘制过程是先清除画布,再绘制整条笔迹。当然这里我们已经采用了一个优化性能的方式,就是分层canvas,绘制中的画笔笔迹使用drawingCanvas,当鼠标释放确定了一条线后,这条线会移动到主画布mainCanvas上,达到动静分离。这样每次取出当前线条的所有点坐标,利用贝塞尔绘制出平滑的曲线。并绘制到最后一个鼠标点位置处,解决跟不上鼠标移动的问题。
第三版解决点擦和线擦不连续的问题
我们实现的橡皮擦除并不是像大家熟悉的方式设置globalCompositeOperation,去盖住原有图形的方式。《清除canvas画布内容--点擦除+线擦除》有详细介绍我们的方法,主要采集鼠标滑过的点利用canvas缓存颜色的图形拾取方式来找到要擦除的图形及具体应该去掉哪几个坐标点,或者哪条线。但是这样如果鼠标滑动很快的话,两个mousemove触发的间隔距离就会很大,那么中间的线都不会被擦除掉。针对这个问题,主要采用了中间补点的方式来模拟增加采集鼠标点的密度。
1 //橡皮优化,鼠标快的时候擦除不干净 2 let dis = XlMath.getInstance().distance(that.eraserLastPoint, p); 3 // let isDraw = false; 4 if (dis > eraserRadius) { 5 let basePoint = that.eraserLastPoint; 6 for (let i = 0; i < 1000; i++) { 7 basePoint = new Point((p.x - that.eraserLastPoint.x) * eraserRadius / dis + basePoint.x, (p.y - that.eraserLastPoint.y) * eraserRadius / dis + basePoint.y); 8 if ((basePoint.x - p.x) * (that.eraserLastPoint.x - p.x) < 0 || (basePoint.y - p.y) * (that.eraserLastPoint.y - p.y) < 0) 9 break; 10 else { 11 let eraserReturn = that.eraser(basePoint); 12 if (eraserReturn) { 13 editor.courseware.draw(false, true); 14 if (currentEditMode == EditMode.elementEraser) 15 editor.bdCanvas.drawPenStatusForElement(true); 16 } 17 } 18 } 19 }View Code
第四版增加笔锋效果
我们的用户反馈别人家的app会有笔锋效果,写出的字就很漂亮,我们能不能也加上。但据我们调查,很漂亮的笔锋效果都是用底层的.net组件或者其他底层语言实现的。但我们也硬着头皮想方法,实现了并不是太完美的笔锋效果,如下图
手写笔迹效果有两个关键点:落笔,收笔1 落笔效果
落笔的地方先绘制个椭圆,椭圆的方向根据前两个点的角度确定:
1 //计算角度 2 ctx.beginPath(); 3 ctx.fillStyle = this.renderStyle.strokeColor; 4 let dire = Util.GetSlideDirection(points[0].x, points[0].y, points[1].x, points[1].y, false); 5 if (dire == 1) {//向上 6 ctx.ellipse(points[0].x, points[0].y + 1.5 / 10 * this.renderStyle.lineWidth / ctx.scaleVal, 5.5 / 10 * this.renderStyle.lineWidth / ctx.scaleVal, 4 / 10 * this.renderStyle.lineWidth / ctx.scaleVal, Math.PI / 4, 0, Math.PI * 2); 7 } else if (dire == 2) {//向下 8 ctx.ellipse(points[0].x, points[0].y - 1.5 / 10 * this.renderStyle.lineWidth / ctx.scaleVal, 5.5 / 10 * this.renderStyle.lineWidth / ctx.scaleVal, 4 / 10 * this.renderStyle.lineWidth / ctx.scaleVal, Math.PI / 4, 0, Math.PI * 2); 9 } else if (dire == 3) {//向左 10 ctx.ellipse(points[0].x + 1 / 10 * this.renderStyle.lineWidth / ctx.scaleVal, points[0].y - 0.5 / 10 * this.renderStyle.lineWidth / ctx.scaleVal, 5.5 / 10 * this.renderStyle.lineWidth / ctx.scaleVal, 4 / 10 * this.renderStyle.lineWidth / ctx.scaleVal, Math.PI * 5 / 4, 0, Math.PI * 2); 11 } else { 12 ctx.ellipse(points[0].x - 1.5 / 10 * this.renderStyle.lineWidth / ctx.scaleVal, points[0].y, 5.5 / 10 * this.renderStyle.lineWidth / ctx.scaleVal, 4 / 10 * this.renderStyle.lineWidth / ctx.scaleVal, Math.PI / 4, 0, Math.PI * 2); 13 } 14 ctx.fill();
2 收笔效果
落笔处的一段线条的线宽需要动态变化,制造慢慢变细的效果(用到了贝塞尔补点):
1 let maxLineWidth = this.renderStyle.lineWidth; 2 let minLineWidth = this.renderStyle.lineWidth / 3; 3 let pointCounter = 0; 4 let points: Array<Point>; 5 if (isUp||this.penType != 1)//不是需要绘制笔锋的线条类型 或者鼠标松开时 6 points = this.points; 7 else 8 points = Util.clone(this.points); 9 //当前绘制的线条最后笔锋处补点 贝塞尔方式增加点 10 if (this.penType == 1 && points.length >= 2) { 11 let i = points.length - 1; 12 let endPoint; 13 let controlPoint; 14 let startPoint = points[i]; 15 let allInsertPoints = new Array<Point>(); 16 while (i >= 0) { 17 endPoint = startPoint; 18 controlPoint = points[i]; 19 if (i == 0) 20 startPoint = points[i]; 21 else 22 startPoint = new Point((points[i].x + points[i - 1].x) / 2, (points[i].y + points[i - 1].y) / 2); 23 if (startPoint && controlPoint && endPoint) {//使用贝塞尔计算补点 24 let dis = (XlMath.distance(startPoint, controlPoint) + XlMath.distance(controlPoint, endPoint)) * ctx.scaleVal; 25 let insertPoints = XlMath.bezierCalculate([startPoint, controlPoint, endPoint], Math.floor(dis / 6) + 1); 26 // 把insertPoints 变成一个适合splice的数组(包含splice前2个参数的数组,第一个参数要插入的位置,第二个参数要删除的原数组个数) 27 insertPoints.unshift(0, 0); 28 Array.prototype.splice.apply(allInsertPoints, insertPoints); 29 points.pop(); 30 } 31 pointCounter++; 32 if (pointCounter >= 6) 33 break; 34 i--; 35 } 36 //赋值最后几个点的线宽 37 let insertCount = allInsertPoints.length; 38 for (let i = 0; i < insertCount; i++) { 39 let w = (maxLineWidth - minLineWidth) / insertCount * (insertCount - i) + minLineWidth; 40 allInsertPoints[i].setLineWidth(XlMath.toDecimal(w)); 41 points.push(allInsertPoints[i]); 42 } 43 }View Code
有了这个效果,代价就是性能了。
几个耗费性能的点:
1)因为一条线段的结尾处在不断变化设置lineWidth;同时也需要多次调用stroke接口
2)使用椭圆api
3)中间计算线宽以及用贝塞尔补点的过程
第五版去笔锋优化画笔流畅度
后来证明对于学校老旧电脑来说,用户流畅度的需求大过于线条的美观度。所以我们又恢复了原来的绘制方式,去掉了笔锋效果。同时从事件响应,收集鼠标坐标点上也做了优化。
对于第二版的优化去掉折线感后带来的鼠标移动笔迹跟不上的问题,我的解决方案每次绘制整条线是有一定的性能影响的,我也曾建议在绘制过程中在drawingCanvas上面绘制的线条容许有折线,鼠标释放笔迹成型后优化掉折线绘制到mainCanvas上,但产品不太接收。后来妥协的接受方式是绘制中笔迹并不能紧跟鼠标的效果。
所以来来回回最后取消了第二次和第四次的改版实现,这个过程也是在平衡笔迹外观和性能的过程,哪个对用户更重要,就往哪个方向改进。
擦除流畅性的限制
前几久产品又提出我们擦除上面的不流畅,不如其他软件的真实流畅,据此我也调研了几种方案:
- 可以将整个canvas画布转化成base64编码的image(调用api方法toDataURL),后面再次绘制的时候把这个image数据再绘制到canvas上,可以继续在这个canvas上进行绘制和擦除内容。但我们黑板的画布是可移动的,所以这个方法会丢掉屏幕之外的线条笔迹,另外线擦除无法使用
- 将画布每个像素点rgb保存到课件(使用api方法getImageData),但存储范围也仅限可视区域,我们黑板的画布是可移动的,所以这个方法会丢掉屏幕之外的线条笔迹,另外线擦除无法使用
- 为解决上面两种方法造成屏幕置为笔迹丢失问题,我们使用globalCompositeOperation设置成destination-out的擦除方法(可以理解成覆盖书写),同时保存拖拽擦除时鼠标经过的点,也就是按照画笔线条的方式 另外保存一份擦除线条的点集合,这个方法会造成课件体积变大,需要数据库支撑,另外也不能实现线擦除,一条线被从中间擦除仍然还是一条线(需要的效果是两条单独的线了),所以会出现擦除混乱的情况。
总结,基于我们业务的复杂性,画布实际上很大可平移,有点擦除和线擦除,只能采用目前的实现方式。