在p5.js程式庫當中所執行的translate等轉換操作,內部的處理機制就是矩陣運算,因此,藉由探索applyMatrix的作法,能夠有助於掌握這類轉換操作,而且,在使用其他繪圖工具時,我們對於矩陣運算的認識,也會影響這類運用上的靈活度。

p5.js常用轉換操作

想使用p5.js繪圖,必須掌握像素座標的運算,動手實作位移、旋轉、縮放等是基本需求,若要減輕實作上的負擔,可以透過p5.Vector,也就是透過向量來表示座標,如此一來,就可以使用p5.Vector的rotate、reflect等方法,減少一些實作時需要的幾何運算細節。

但是,p5.js未內建位移、旋轉、縮放等轉換操作嗎?其實是有的,在官方參考文件的〈Transform〉就列有translate、rotate、scale等函式,不過,初學者可能對這些函式的使用感到困惑,因為它們並不是針對某個像素座標,而是針對整個畫布中的像素。

舉例來說,translate會改變其後續要繪製的像素位置,若撰寫translate(width/2, height/2);circle(0, 0, 10);,會以畫布正中為圓心繪圓,如果想讓圓以畫布左上角為圓心、轉動30度呢?這需要兩次的轉換操作,也就是寫為rotate(30);translate(width/2, height/2);circle(0, 0, 10);。

嗯?為什麼是先寫rotate後,才寫translate?轉動後再位移?不!實際效果會是位移後才轉動!

初學者在結合兩個以上的轉換時,經常會有上數這種困擾,以為轉換操作必須就程式碼的撰寫順序來倒著閱讀。但是,閱讀的順序,其實代表的是矩陣乘法的順序!rotate相當於運用了一個旋轉矩陣R,而translate相當於使用了一個旋轉矩陣T,因此,如果想讓圓以畫布左上角為圓心轉動,矩陣乘法就是R*T!

p5.js官方API文件上也談到,轉換操作是累計的(cumulative),每次重新呼叫draw時會重置,如果想將某些轉換操作獨立設計為一個單元,在這個單元中的操作不想被單元之後的繪圖使用,可以使用push、pop──push會將當前轉換操作置入堆疊,無論之後做了什麼轉換操作定,都可以透過pop回復至push前的設定。

使用applyMatrix

除了常用的translate、rotate、scale等函式,p5.js也提供applyMatrix,我們可以指定轉換矩陣,而且,translate、rotate、scale等函式所能做到的事,都可透過applyMatrix、指定對應的轉換矩陣做到,而每次呼叫applyMatrix,就相當於做了一次矩陣乘法。

有趣的是,applyMatrix的參數順序。在其API文件提及,參數順序是根據WHATWG對transform的規範,只是為什麼如此規範?這就等於在提出一個問題,怎麼以程式碼來表示矩陣呢?直覺上,我們會想到陣列,那麼,該怎麼表示呢?

舉例來說,想要建立2D版本的位移矩陣,若tx、ty表示x、y方向的位移量,若使用一維陣列表示時,是寫為[1,0,tx,0,1,ty,0,0,1],那就是「以列為主(row-major)」的實現方式,因為是以[row1 row2 row3]的方式來撰寫。

此時,若寫為[1,0,0,0,1,0,tx,ty,1],則是「以行為主(column-major)」的實現方式,因為,這是以[column1 column2 column3]的方式來實現矩陣。

無論是「以列為主」或「以行為主」,純粹只是標示上的問題,採用哪個都行,各有其支持者。

「以列為主」的實現,透過適當排列,能符合矩陣的視覺效果,第一次接觸矩陣的開發者通常直覺上會採取「以列為主」,有些工具(例如DirectX)也採取列為主。

至於採取「以行為主」的工具,通常與OpenGL有關,因其規格書及參考手冊採取「以行為主」(也就經常造成許多習慣以列為主的開發者接觸OpenGL時的誤用)。

以2D方式使用applyMatrix,就參數的安排上,其實就是行為主的撰寫方式,然而不寫出0、0與1的部份,在applyMatrix的API文件中有個3D版本的使用範例,可以與旋轉矩陣對照一下,就會發現參數安排正是完整的行為主撰寫方式。

使用矩陣程式庫

瞭解applyMatrix的使用方式後,若要尋找程式庫來協助矩陣運算,可以考慮glMatrix——其實,它是為了WebGL而生的程式庫,然而因為它實作矩陣時是以行為主,也就可以使用於p5.js,對於方才圓以畫布左上角為圓心轉動的需求,也可以如下實現:

const m = mat3.create(); // 單位矩陣
mat3.rotate(m, m, angle * PI / 180);
mat3.translate(m, m, [width / 2, height / 2]);
applyMatrix(...forApplyMatrix(m));
circle(0, 0, 10);

這邊的重點在於先寫了rotate再translate,這是因為它們內部進行了矩陣乘法,而且,是以「後乘(Post-Multiplication)」,或稱為「右乘(Right-Multiplication)」的方式實現。

想知道這代表什麼,必須先知道矩陣乘法沒有交換率,A*B與B*A的結果不一定相同,於是,這就引發了一個問題,若目前已有矩陣M,與另一個矩陣T相乘,順序上該怎麼安排呢?

若是M*T,表示M後乘T,相對地,若是T*M,表示M前乘(Pre-Multiplication)或左乘(Left-Multiplication)T,glMatrix採用後乘的原因在於,rotate、translate的內部實作,就是在進行矩陣乘法,若從一個單位矩陣開始,想建立矩陣R*T的結果,內部相當於實現了:

multiply(R)
multiply(T)

如此在書寫上就符合R*T的順序,glMatrix以行為主、後乘的實現方式,其實,這是因為WebGL承自OpenGL,而OpenGL的慣例正是以行為主與後乘(然而,也有採用前乘的工具,例如DirectX)。

p5.js也符合這個慣例的原因,在於translate等轉換操作,背後都是矩陣運算,而可以累計的原因在於,內部維護著一個全域的矩陣,每個轉換操作(包括applyMatrix),都會對它執行右乘。

p5.s有個resetMatrix可以將內部矩陣重置為單位矩陣,其實每次draw前,轉換操作都會被重置,就是因為內部矩陣被重置為單位矩陣;p5.js的push函式,則是將內部全域矩陣複製一份置入堆疊,之後pop的話,會取出堆疊中的矩陣作為全域矩陣,因此可以回復先前的操作狀態。

掌握矩陣運算的實現

在p5.js,一旦認知到內建的轉換操作,內部都涉及矩陣運算,實現上「以行為主」且採取後乘,之後,我們對於translate、applyMatrix、resetMatrix、push、pop的使用時機,就不容易混淆了。

實際上不只p5.js,繪圖相關的工具或程式庫涉及轉換操作時,背後往往就是矩陣運算,只不過採取的實現方式可能不同。例如,我常玩的OpenSCAD是採取二維陣列、以列為主、後乘方式實現,可搭配multmatrix來使用。

無論如何,只要能掌握各自工具或程式庫的實現方式,對於轉換操作就都能靈活運用了。

專欄作者

熱門新聞

Advertisement