如何繪製完美的滑鼠軌跡

沉默的老虎發表於2021-11-03

動機

在公司的某次週會上,我吐槽了某產品中一個顯示滑鼠軌跡的效果實現得比較抽象:

mouse track

可以看到它的實現方式是將 mousemove 事件觸發時的座標,用長寬不一的矩形連線起來,所以連線處出現了明顯的“斷裂”,整個軌跡也不平滑,而且其寬度和透明度的“漸變”也比較生硬,有明顯斷層。

而我理想中的滑鼠軌跡應該是長這樣的:

整個軌跡是一條相對平滑的曲線,中間不應該有生硬的“斷裂”,而且軌跡的寬度和透明度都均勻變化。

當時我覺得這麼簡單一個效果完全應該做得完善一點,也花不了多少時間。

然而,一個週末的中午,我正在洗碗,突然腦子裡靈光一閃,我意識到,在 web canvas 上要實現一個「完美」的滑鼠軌跡效果似乎並沒有想象的那麼簡單。於是我決定自己嘗試一下,就有了這個專案。

問題

所謂「並沒有想象的那麼簡單」主要是要解決這幾個問題:

  • 通過 mousemove 事件獲取的滑鼠軌跡是離散的座標點,而不是真實的軌跡曲線,如何通過離散座標繪製平滑曲線?
  • 滑鼠軌跡的透明度應該是漸變的,web canvas 上並沒有提供在一個 path 上做線性漸變的介面,這個效果如何實現?
  • 滑鼠軌跡的粗細也應該是漸變的,web canvas 上的單一 path 也沒有提供畫筆粗細漸變的介面,這個效果又如何實現?

方案

如何通過離散座標繪製平滑曲線?

如果你用過 Photoshop 中的鋼筆工具,答案其實就很簡單,用貝塞爾曲線。Photoshop 中的鋼筆工具其實就是一個貝塞爾曲線編輯器,通過起點、終點以及兩個控制點,就可以在起點和終點間建立一條曲線。

而如果一箇中間點上的兩個控制點滿足一定的規律,就可以實現曲線的連續,也就是視覺效果上的平滑。感興趣的話可以閱讀「用鋼筆工具繪圖」中的內容。

那麼中間點上的兩個控制點滿足什麼樣的規律就可以實現曲線的連續呢?其實也很簡單,就是中間點和兩個控制點在同一直線上即可。

如下圖,滑鼠經過 A、B、C 三點,此時 B 點和他的兩個控制點 C1 和 C2 在同一直線上,整個曲線在 B 點處就是平滑的。其數學邏輯也很簡單,三點處於同一直線就意味著 B 點在 C1 方向和 C2 方向上的斜率都相同,這樣曲線就平滑了。

那麼,在已知 A、B、C 三點座標的情況下如何計算出每個點的控制點呢?一個簡單的辦法如下如所示:

  1. 計算角 p1-pt-p2 的角平分線,以及此角平分線經過點 pt 的垂線 c1-pt-c2
  2. 取 p1、p2 在 c1-pt-c2 上的投影點中距離 pt 點較近的點 c2
  3. 在 c1-pt-c2 上取與 c2 點相對 pt 對稱的點 c1

此時用計算出的 c1、c2 點作為 pt 點的控制點,就能生成一個效果不錯的平滑曲線了,同時 c1、c2 到 pt 點的距離還可以用一個 tension 引數進行調節,從而達到調節曲線平滑程度的作用。

如何在曲線上實現寬度的漸變?

首先,CanvasRenderingContext2D 這套 API 並沒有提供描邊路徑時漸變筆刷寬度的介面,也就是說,如果僅僅用 bezierCurveTostroke 這兩個介面是沒有辦法實現像文章開始時描述的那種「完美」的滑鼠軌跡的。

解決這個問題的其中一個辦法,就是把路徑變為形狀。簡單來說,就是把一段有寬度的貝塞爾曲線,看做是由兩條曲線和兩條直線所圍成的圖形:

中間黑色的曲線用一個有寬度的畫筆描邊之後,其實和紅色區域填充之後的效果是一樣的,這就是所謂把路徑變為形狀。這樣一來,我們根據需要來調整紅色線框的形狀,就可以實現一個看起來畫筆寬度漸變的曲線了,至於如何計算這個線框這裡先按下不表。

如何在曲線上實現透明度的漸變?

同樣的,CanvasRenderingContext2D 這套 API 也沒有提供描邊路徑或填充區域時漸變筆刷透明度的介面。這時就不得不使用「分割」法來模擬一個漸變效果了。也就是說,如果有一段曲線在繪製時需要將畫筆透明圖從 1 變為 0,我們就把這條曲線分割成 100 個曲線片段依次繪製,並且繪製這些片段時所用的透明度逐漸變化,這樣就可以在視覺上實現透明度漸變的效果了。

如上圖所示,我們可以在一條貝塞爾曲線上計算出若干個點,用這些點把這條曲線分割成多條曲線,然後給與每條曲線不同的透明度,這樣在視覺上就可以實現類似透明度漸變的效果。

但細心的同學肯定會發現一個問題,上圖中分割點之間的距離是不一樣的,這裡又涉及到一個概念:勻速貝塞爾曲線。三次貝塞爾曲線的公式如下:

所以如果我們讓輸入,也就是 t 在 [0, 1] 上勻速變化,得到的值則不是勻速的,也就是上圖中空心圓點的距離是不同的。但是,要計算出均勻分割貝塞爾曲線的點非常麻煩,往往需要迭代計算才能求得一個近似值。

然而,就算用簡單的分割方法,只要分割的數量夠多,比如分割成 50 段,人眼也基本上看不出來透明度的變化是不均勻的,所以實際使用場景中沒有必要一定要算出均勻分割的點。

另外,分割法事實上也同樣可以解決上面寬度漸變的問題,把曲線分割成若干段,給與每一段不同的線寬,曲線的寬度看起來就是均勻變化的了,而且這種辦法事實上比上面講的計算曲線邊框的辦法速度更快。

總結

相關程式碼我已經封裝成了一個 npm 包:laser-pen,歡迎 start、issue、pr

沒事多洗洗碗。說不定就會有意外的收穫 ?

相關文章