記canvas畫筆筆跡的多次優化過程

方帥發表於2021-04-28

我們的專案是面向學校老師的教學軟體,所以肯定少不了互動白板的功能,而這個裡面的畫筆功能是由我來開發的,下面介紹這個過程中遇到的問題以及解決方法。

首先給大家明確下由於軟體中的畫布可以自由移動,會超出螢幕顯示範圍,同時支援點擦線擦,所以需要儲存所有點座標

第一版簡單畫筆實現並優化掉折線感

第一版實現的肯定是很簡單的畫筆線條,由給定的滑鼠座標位置連線畫出線段,主要使用的canvas的API方法有:beginPath moveTo  lineTo stroke。不過很快發現當滑鼠快速畫曲線時出現很明顯的拐點,這裡要用到貝塞爾曲線來解決,具體可參考《利用貝塞爾繪製平滑曲線》。

第二版解決快速畫線時筆跡跟不上滑鼠移動的問題

實現了貝塞爾曲線的繪製,同時也產生新的問題,繪製過程中會出現線條的延長跟不上滑鼠的情況(這是由於貝塞爾曲線的應用引起的,二次貝塞爾曲線繪製的時候需要三點確定起始點和控制點,《利用貝塞爾繪製平滑曲線》有具體講解,看懂就能明白為什麼會跟不上了)。

由於我們儲存了所有點座標,所以解決這個題也好辦,就是mousemove觸發繪製時都遍歷一遍本條線上所有點來繪製這條線

所以每次滑鼠移動採用的繪製過程是先清除畫布,再繪製整條筆跡。當然這裡我們已經採用了一個優化效能的方式,就是分層canvas,繪製中的畫筆筆跡使用drawingCanvas,當滑鼠釋放確定了一條線後,這條線會移動到主畫布mainCanvas上,達到動靜分離。這樣每次取出當前線條的所有點座標,利用貝塞爾繪製出平滑的曲線。並繪製到最後一個滑鼠點位置處,解決跟不上滑鼠移動的問題。

第三版解決點擦和線擦不連續的問題

我們實現的橡皮擦除並不是像大家熟悉的方式設定globalCompositeOperation,去蓋住原有圖形的方式。《清除canvas畫布內容--點擦除+線擦除》有詳細介紹我們的方法,主要採集滑鼠滑過的點利用canvas快取顏色的圖形拾取方式來找到要擦除的圖形及具體應該去掉哪幾個座標點,或者哪條線。但是這樣如果滑鼠滑動很快的話,兩個mousemove觸發的間隔距離就會很大,那麼中間的線都不會被擦除掉。針對這個問題,主要採用了中間補點的方式來模擬增加採集滑鼠點的密度。

記canvas畫筆筆跡的多次優化過程
 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 收筆效果

落筆處的一段線條的線寬需要動態變化,製造慢慢變細的效果(用到了貝塞爾補點):

記canvas畫筆筆跡的多次優化過程
 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上,但產品不太接收。後來妥協的接受方式是繪製中筆跡並不能緊跟滑鼠的效果。

所以來來回回最後取消了第二次和第四次的改版實現,這個過程也是在平衡筆跡外觀和效能的過程,哪個對使用者更重要,就往哪個方向改進。

擦除流暢性的限制

前幾久產品又提出我們擦除上面的不流暢,不如其他軟體的真實流暢,據此我也調研了幾種方案:

    1. 可以將整個canvas畫布轉化成base64編碼的image(呼叫api方法toDataURL),後面再次繪製的時候把這個image資料再繪製到canvas上,可以繼續在這個canvas上進行繪製和擦除內容。但我們黑板的畫布是可移動的,所以這個方法會丟掉螢幕之外的線條筆跡,另外線擦除無法使用
    2. 將畫布每個畫素點rgb儲存到課件(使用api方法getImageData),但儲存範圍也僅限可視區域,我們黑板的畫布是可移動的,所以這個方法會丟掉螢幕之外的線條筆跡,另外線擦除無法使用
    3. 為解決上面兩種方法造成螢幕置為筆跡丟失問題,我們使用globalCompositeOperation設定成destination-out的擦除方法(可以理解成覆蓋書寫),同時儲存拖拽擦除時滑鼠經過的點,也就是按照畫筆線條的方式 另外儲存一份擦除線條的點集合,這個方法會造成課件體積變大,需要資料庫支撐,另外也不能實現線擦除,一條線被從中間擦除仍然還是一條線(需要的效果是兩條單獨的線了),所以會出現擦除混亂的情況。
      總結,基於我們業務的複雜性,畫布實際上很大可平移,有點擦除和線擦除,只能採用目前的實現方式

相關文章