開發中的新版本 BPS 當中,其中一個相當大的變更是把圖形處理程式庫從 paper.js 遷移到 pixi.js 之上。本篇當中我想來分享一下這當中的歷程。

paper.js

在最最一開始我開發 BPS 的時候,其實我並沒有立刻想要去找一個繪圖程式庫、而是試著自己用 <canvas> 元件的 API 去進行畫面的繪製。只不過我很快地就意識到我浪費了太多時間在發展一些在程式庫裡面肯定已經處理好了的事情,例如圖層管理、群組內部的座標變換、不受縮放影響的線條粗細等等 1。所以我於是開始尋找比較各種現成的 JavaScript 繪圖程式庫。

在各種標榜著「繪圖」的程式庫當中,paper.js 很快地就讓我看上了。這有兩大原因:

  1. 首先,它是特別標榜向量圖形繪製的程式庫,甚至內建了向量布林運算的功能,這點基本上沒有別的繪圖程式庫有提供,而且我又剛好會需要用到。
  2. 在我開始開發的幾年之前,剛好 Google 底下有個團隊開發了一個叫 RACER 的實驗性連線賽車遊戲,他們也是選擇了 paper.js 來繪製畫面,而且在他們的開發文章中還特別提到「因為它效能夠好」。我想說連 Google 底下的團隊都用了,應該不會錯吧?

於是我就決定用 paper.js 來當作 BPS 的繪圖引擎,並且從最初的版本一直到現在用了兩年多。起初,確實因為有了它內建的向量布林運算以及輸出 SVG 格式等功能讓我用得滿滿意的,我甚至還在網站上寫上了「感謝 paper.js 作者」這樣的話。除此之外這個程式庫也對於我後來的專案開發有了不少的啟發,包括我後來也愛用 Gulp 來進行自動建置也是受到它的影響。

然而,陸陸續續我開始發現,其實 paper.js 沒有我想像中那麼好。

paper.js 的問題

要說 paper.js 哪裡有問題,這還真的不是三言兩語講得完的。底下一一討論。

並沒有加以模組化,無法搖樹

無論是去看 paper.js 的 GitHub 原始碼 或是去看最後發布的檔案,我們都會發現這個程式庫完全沒有把其中的組件加以模組化。也就是說,我就只能把它整個套件照單全收,包括裡面一堆我用不到的功能也是,這既增加了下載的傳輸量、也減緩了程式的啟動速度——除非我自己 fork 其原始碼、刪除掉用不到的部份然後重新建置我自己要用的版本(事實上我那時也真的這麼做了,可參見這個 repo)。

但是比較好的 JavaScript 程式庫不應該是這樣的;好的程式庫都會用 ECMAScript 的 import/export 語法來進行模組化,這使得各種建置工具可以根據實際上會使用到的部份來進行搖樹(tree shaking)的建置優化動作,亦即去除掉一些不會被使用到的程式碼、減小建置大小並加快載入速度。

繪製效能其實並沒有比較高

是的,paper.js 當然繪製並不慢,但那是因為 Canvas API 的效能本來就不錯,不是因為 paper.js 在繪製方面還有特別做了什麼樣的優化。仔細觀察它的原始碼會發現,其實它也就是很單純地每次都把畫布清空、然後按照圖層與物件的順序一一把東西畫上去,如此完成一回合收工,沒有任何演算法觀點上的特殊之處(例如它不會特別去判斷是否只要局部重繪就好、或是根據當前的視角來快速排除一些其實根本不用畫的東西)。這樣一來,其實我當初自己寫的那些程式碼的繪製效能也絲毫不會輸給它,而且我可能還可以省去很多對我來說並不必要的 overhead。所以到頭來,paper.js 只是幫我省下了開發的時間,而沒有幫我賺到任何的執行效能。

除此之外,paper.js 是基於 CanvasRenderingContext2D 的繪圖程式庫,這種繪圖模式本來就有其效能上的侷限,而比不上 WebGL 或是更新的 WebGPU。曾經有人向其作者提議過 WebGL 的可能性(參見此串),但是作者直接認定「那超出本程式庫範疇了」而否決掉。

不過我再次澄清,視應用而定,paper.js 並不是一個繪製上沒效率的程式庫;如果各位不用繪製大量的物件(尤其是曲線物件)、而且對象又是以桌機為主的話,這個程式庫絕對是夠用的,但是要是跟我一樣偏偏有大量繪製曲線的需求、而且又需要支援手機的話,那基本上它是不夠力的。

雖然有提供向量布林運算,但是很慢

雖然 paper.js 確實有內建向量圖形的布林運算(例如算出形狀的交集或聯集)的功能,但是它所採用的演算法卻相當沒有效率 2。隨著 BPS 陸續發展,這一段的效能瓶頸就越來越明顯了,後來我不得不自己去尋找更快的演算法來解決這一段的問題。針對多邊形的布林運算的部份,我那時找到了基於 Martínez-Rueda-Feito 演算法寫成的 polybooljs 程式庫,它明顯地比起 paper.js 的運算快上許多;而至於曲線形狀的布林運算,我那時在無計可施的情況下,只能設法開一堆 worker 來平行運算以加快那部份的速度(詳情可見本系列的第五篇)。

不過,隨著新版 BPS 的開發,去年底我自己花了不少時間研讀關於計算幾何的一些書和論文,我才了解到了幾件事:

  1. polybooljs 其實把 MRF 演算法實作得很差,它都沒有用到堆積或是二元搜尋樹這些比較高效率的資料結構在處理演算法當中的一些關鍵細節,而且它的邊篩選邏輯也複雜得很沒必要。
  2. 針對我的應用情境來說,MRF 演算法裡面有一個環節(終點事件之後重新檢查新的交點)是可以完全被省略掉的;那對於我的應用來說是不可能發生的。
  3. MRF 演算法只要稍微加以修改,也一樣可以套用在曲線形狀的布林運算之上,因為 MRF 的核心精神並不因為牽涉到的形狀有曲線邊就有所不同。

所以在開發中的新版本裡頭,我自己把 MRF 演算法從頭到尾重新實作了一遍、並且加入了上述的優化與修改(而且我還用上了 2016 年新發表的 RAVL 樹),最後總算是完成了一個快到爆的演算法,即使只用單一執行緒跑全部的運算也遠遠不再是效能瓶頸之所在了。從而 paper.js 的這部份功能我也再也不需要了。

它的 SVG 輸出功能也沒有做得很好

雖然它確實有內建把當前的畫布內容輸出成 SVG 格式的功能,但是它輸出的速度又慢、輸出的檔案大小又大得不合理。針對某些比較複雜的畫面,它甚至會需要跑上十幾秒才能夠完成輸出。後來我發現那是裡面的一個 bug 導致的問題並且回報在此,但是作者根本沒有理我。

至於輸出檔案很大的這部份,這當然是因為該程式庫是提供最一般性的輸出、而沒有辦法針對我的情境來加以優化的緣故;例如它只能夠把物件的樣式一一寫在每一個物件上頭,而無法整理成一個統一的樣式表並且讓物件套用 class 就好等等。在開發中的新版本裡頭,我於是也自己去實作了 SVG 的輸出。其實也沒什麼難的,搞懂 SVG 語法就做得到,開發時間也用不到幾天的時間。

基本上已經沒什麼在維護了

如果要問我最終決定捨棄掉 paper.js 的關鍵原因,那當然就是因為作者打從 2021 年三月釋出了0.12.15 版之後幾乎就沒有再經營這個程式庫了。是啦,嚴格來說之後他還是有釋出兩個更新版本,但是都是不怎麼重要的小更新,而 GitHub 上頭累積的大量 issue 以及 PR 他則是根本都不管。

至於是什麼原因,我自然是不清楚了;也許他生活中別的事情太過忙碌?也許他那沒有模組化也沒有用 TypeScript 的 code base 讓他覺得難以維護?也許隨著 WebGL 和 WebGPU 的新技術陸續出現讓他失去了對這個程式庫的熱情?或者又也許其實他並沒有放棄,而是跟我一樣、暗中大動作地想全部重新改寫一番?

我不知道,而且不管是什麼原因,我也都不會怪他;只是站在使用者的角度,這樣的維護度我是沒辦法繼續再使用下去就是了。

遷移到 pixi.js

OK,那如果不用 paper.js 的話,該改用什麼呢?那個時候我找到了一個頗完整的 benchmark 在比較許多繪圖程式庫的繪製效能,結果一比下去 pixi.js 根本是屌打群雄,只有它能夠在繪製到 8000 個移動中的正方形的時候還能輕鬆作到 60fps 的流暢度;相較之下,在同樣的裝置中,paper.js 只能作到 20fps 左右。看到這個結果就讓我對 pixi.js 產生了很高度的興趣,而繼續調查下去之後,就越來越看到它的優勢:

  1. 社群規模完全不同。相較於 paper.js 的 13.6K 個星與 81 位貢獻者,pixi.js 則有 39.2K 個星和 430 位貢獻者。作為結果,pixi 的持續開發和更新都相當活躍,每個月(甚至每隔一兩週)都會推出新版。
  2. 它是基於 WebGL 的渲染引擎 3,並且有持續考慮在未來採用 WebGPU 的可能性,未來的發展路線圖和時程都很明確。
  3. 它是一個徹底模組化而且可以搖樹的程式庫,非常容易可以作到只匯入我要用的模組。
  4. 它的文件網站遠比 paper.js 要完整而清楚且有在維護,它甚至還有它自己的 Discord 伺服器可以討論。
  5. 它的背後有 GoodBoyDigital 公司在撐腰,還有許多的贊助者,經營上穩定得很多。
  6. 它還有對應的 Chrome 擴充 開發工具,可以方便偵錯繪製上遇到的問題。4

然而,雖然有著這麼多的絕對優勢,我並沒有很快就做出遷移的決定,畢竟兩者的架構完全不同,沒有那麼簡單可以寫出一個能橋接兩者的介面來自由切換、而是幾乎要全盤改寫所有相關的程式碼;再加上早先我並沒有那麼大的把握能夠自己寫出曲線多邊形的布林運算演算法,所以那一部份多少有點被 paper.js 給技術綁架了。我真的徹底下定決心做遷移,也是一直等到去年底我把該演算法成功實作出來之後的事。

即便如此,遷移過程倒也還有一些小坑要填,畢竟 pixi.js 認真說起來並不是一個以「繪圖」為主要目的的程式庫,而是一個以遊戲類型的 app 為主要目標的渲染引擎,也就是說它主要操作的對象是 sprite 和 texture 等等,繪圖功能只是順便提供的而已。底下列出一些我遇到的坑。

線條的反鋸齒

跟 Canvas API 相比之下,用 WebGL 進行繪圖本來就有一些為人熟知的反鋸齒問題(參見官網的範例;各位會注意到斜線的反鋸齒都沒有做得很好)。

幸好,這部份有一個外掛 @pixi/graphics-smooth 專門解決了這個問題,而且該套件還提供了 LINE_SCALE_MODE.NONE 的選項可以輕鬆達到我要的「與縮放無關的線條粗細」之效果。然而,該套件倒是有另外一個很小的坑:當它在使用 drawCircle 等方法進行繪製的時候,「它打算用幾個線段來逼近圓弧」的這個問題完全取決於輸入參數的半徑,如此一來,如果局部的座標被放得很大、導致半徑數值本身很小的話,畫出來的圓弧就會看起來不像圓而像個多邊形。關於這個問題我有在其中一個 issue 中加以回報,不過截至本文寫成為止尚未有正解;我暫時的解決方法就是在圓弧物件本身再加上一個縮小、以便能傳入較大的半徑來迫使圓弧繪製得更平滑。

路徑填色

pixi.js 在給路徑填色的時候、相較於無論是 paper.js、Canvas API 或是 SVG 來說都顯得有一點麻煩。對於有洞的路徑來說,後面的幾個都可以簡單地透過 even-odd 填補規則來把所有路徑以任何的順序畫上去、然後它們會自動判斷哪些地方是要填滿的、哪些地方是洞,但 pixi.js 目前為止是沒有內建這種機制的。它提供的是較為低階的 beginFill/endFillbeginHole/endHole 方法,這些方法使用的前提是我們必須自己把路徑的外圍和洞配對好、以正確的順序呼叫這些方法,畫出來才會是對的。幸好,新版 BPS 採用的核心演算法只要稍微加以修改就可以自動產生這樣的配對資料出來,所以也不算太辛苦就把這個坑解掉了。

啟動選項設置

在建構 pixi.js 的 Application 物件的時候,官方文件上是說可以把 resolution 設定為 devicePixelRatio,但我發現這邊有一個小細節在於 resolution 選項不該傳入非整數,不然格線繪製出來的結果會很不均勻。經過實驗,我發現取 floor 就可以在各種裝置上都有良好的繪製表現,像下面這樣:

const pixiApp = new Application({
    ...
    resolution: Math.floor(devicePixelRatio),
    autoDensity: true,
    antialias: true,
    ...
});

點擊測試

跟不少類似的程式庫一樣,pixi.js 也有內建點擊測試的功能,不過它只會傳回滑鼠點擊位置上的最上層物件,而在 BPS 當中我則有一個需求是要傳回點擊位置上重疊著的所有物件。要作到這一點,一個方法是去擴充內建的 EventBoundary 類別:

class ControlEventBoundary extends EventBoundary {

    public $hitTestAll(sheet: Sheet, location: Point): DisplayObject[] {
        let result: DisplayObject[] = [];
        this._hitTestAllRecursive(sheet.$view, this.rootTarget.eventMode, location, result);
        return result;
    }

    private _hitTestAllRecursive(
        target: DisplayObject,
        eventMode: EventMode,
        location: Point,
        result: DisplayObject[]
    ): void {
        if(!target || !target.visible) return;
        if(this.hitPruneFn(target, location)) return;

        const interactive = eventMode === "static" || eventMode === "dynamic";
        if(target.interactiveChildren && target.children) {
            for(const child of target.children) {
                this._hitTestAllRecursive(
                    child as DisplayObject,
                    interactive ? eventMode : child.eventMode,
                    location,
                    result
                );
            }
        }
        if(interactive && this.hitTestFn(target, location)) {
            result.push(target);
        }
    }
}

如此一來我們就可以借用 EventBoundary 當中定義的 hitTestFnhitPruneFn 等方法、而不用自己全部重刻。然後再用如下的方法把這個自訂的類別註冊上去:

// 此工具類別可以用來寫入一些被宣告成唯獨的屬性
type Writeable<T> = { -readonly [P in keyof T]: T[P] };

const renderer = pixiApp.renderer;
export const boundary = new ControlEventBoundary(pixiApp.stage);
(renderer.events as Writeable<EventSystem>).rootBoundary = boundary;

完成了之後就可以呼叫 boundary.$hitTestAll() 來達成前述需求了。

結語

其實本篇說穿了主要就是在靠北 paper.js 的廢文,關於 pixi.js 的心得只是附贈的。類似的靠北文在 BPS 的開發過程中還有更多對象可以拿來開刀,不過特別會寫針對 paper.js 一方面是因為繪圖程式庫是 BPS 的主要相依之一,另一方面 paper.js 特別是我從原本用得很開心、到後來變成完全搖頭的鮮明例子。

若要說我自己從這裡頭學到什麼經驗的話,我覺得總結起來就是:專案最主要的相依程式庫還是要挑選夠成熟、社群夠大、經營夠穩、且開發者對於各種反饋的回應態度積極的比較好。有了這樣的前提之後,就算有些什麼不完美之處也比較能期待它修正。


  1. 當然這些如果我改採用 SVG 來渲染畫面的話都是很容易做到的,但是 SVG 的繪製效能是遠遠不如 <canvas> 的(尤其在手機上更是如此),所以我很早就排除了這個選項。 

  2. 大致上它採用的方法是先用線性搜尋(透過矩形範圍檢查來稍微加快)找出兩個路徑的所有交點、分解路徑成線段、計算出線段的卷繞數(winding number)貢獻,然後在根據卷繞數來決定要篩選出哪些線段;這每一個步驟都很慢。其程式碼在此。 

  3. 它也有提供 canvas 的 fallback,但是從 v7 開始不再列為核心模組之一。 

  4. 這有點類似 Vue 或 React 的開發工具擴充,不過有點可惜的是這並非 pixi.js 官方團隊所開發、所以投入的資源以及整合程度相對差強人意。我主要是在剛入門 pixi.js 的時候比較需要用它來釐清一些我不確定的細節,上了軌道之後我就沒使用了。 


分享此頁至:
最後修改日期: 2023/03/16

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。您的留言可能會在審核之後才出現在頁面上。