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


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

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

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

第一版實現的肯定是很簡單的畫筆線條,由給定的鼠標坐標位置連線畫出線段,主要使用的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上,但產品不太接收。后來妥協的接受方式是繪制中筆跡並不能緊跟鼠標的效果。

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

擦除流暢性的限制

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

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

相關文章:

相關文章:

平滑曲線

實現蠟筆熒光筆效果

實現筆鋒效果

畫筆性能優化

清除canvas畫布內容--點擦除+線擦除


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM