寫個 2D 矩陣庫


在〈認識矩陣〉中談到,如果你的計算過程涉及矩陣運算,想以最後運算出來的矩陣做轉換,可以使用 applyMatrix,那麼來寫個簡單的矩陣程式庫吧!

那麼該怎麼以程式碼來表示矩陣呢?應該直覺地會想到,可以使用 JavaScript 陣列,那麼該怎麼表示呢?以位移矩陣為例:

寫個 2D 矩陣庫

你會怎麼用陣列表示呢?如下嗎?

[
    1, 0, tx, 
    0, 1, ty, 
    0, 0, 1
]

視覺上看來,這似乎比較符合矩陣表示法,若你是這麼想,表示你支持列為主(row-major)的派別,在線性陣列中實現時是逐列編寫,也就是這派是這麼看待陣列中的矩陣元素的:

[
    row1
    row2 
    row3 
]

不過,其實有行為主(column-major)的實現方式:

[
     1,   0,  0, 
     0,   1,  0, 
     tx, ty,  1
]

也就是在線性陣列中實現時是逐列編寫,也就是這派是這麼看待陣列中的矩陣元素的:

[column1 column2 column3]

那麼 p5.js 中該怎麼做呢?根據 applyMatrix 的 API 文件,若有個矩陣:

寫個 2D 矩陣庫

要指定給 applyMatrix 的話,參數順序是 applyMatrix(a, b, c, d, e, f),如果你排版一下:

applyMatrix(
    a, b,
    c, d,
    e, f
);

看來像是以列為主,只不過 0、0 與 1 的部份不用指定;實際上,applyMatrix 可以指定 3D 轉換矩陣,p5.js 也有 3D 版本的轉換函式,以 rotateY API 文件中的這個範例為例:

若要套用轉換矩陣列話,可以如下:

目前還沒有正式介紹 3D 的處理,焦點可以先放在 applyMatrix 接受的參數,其實就是以下轉換矩陣以行為主的表示:

寫個 2D 矩陣庫

那麼就使用行為主的方式來表達矩陣,來定義出位移、縮放、旋轉與反射等操作:

const mat3 = {
    // 移動矩陣
    translation(tx, ty) {
        return [
             1,  0, 0,
             0,  1, 0,
            tx, ty, 1
        ];
    },

    // 縮放矩陣 
    scaling(sx, sy) {
        return [
            sx,  0,  0, 
             0, sy,  0,  
             0,  0,  1 
        ];
    },

    // 旋轉矩陣
    rotation(degree) {
        const c = cos(degree);
        const s = sin(degree);
        return [
             c, s, 0, 
            -s, c, 0,
             0, 0, 1, 
        ];
    },

    // 反射矩陣(以 (x, y) 向量為鏡)
    reflection(x, y) {
        const uu = pow(createVector(x, y).mag(), 2);

        const xx = x * x;
        const yy = y * y;
        const xy2 = x * y * 2;

        return [
            (xx - yy) / uu, xy2 / uu, 0,
            xy2 / uu, (yy - xx) / uu, 0,
            0, 0, 1,
        ];
    }, 

    // 移動
    translate(m, tx, ty) {
        return this.multiply(m, this.translation(tx, ty));
    },

    // 縮放
    scale(m, sx, sy) {
        return this.multiply(m, this.scaling(sx, sy));
    },        

    // 旋轉
    rotate(m, degree) {
        return this.multiply(m, this.rotation(degree));
    },            

    // 反射(以 (x, y) 向量為鏡)
    reflect(m, x, y) {
        return this.multiply(m, this.reflection(x, y));
    },  

    // 轉為 applyMatrix 可用參數
    forApplyMatrix(m) {
        return m.filter((elem, idx) => (idx + 1) % 3 !== 0);
    },        

    // 矩陣相乘
    multiply(a, b) {
        const a00 = a[0], a01 = a[1], a02 = a[2];
        const a10 = a[3], a11 = a[4], a12 = a[5];
        const a20 = a[6], a21 = a[7], a22 = a[8];

        const b00 = b[0], b01 = b[1], b02 = b[2];
        const b10 = b[3], b11 = b[4], b12 = b[5];
        const b20 = b[6], b21 = b[7], b22 = b[8];

         return [
             b00 * a00 + b01 * a10 + b02 * a20,
             b00 * a01 + b01 * a11 + b02 * a21,
             b00 * a02 + b01 * a12 + b02 * a22,

             b10 * a00 + b11 * a10 + b12 * a20,
             b10 * a01 + b11 * a11 + b12 * a21,
             b10 * a02 + b11 * a12 + b12 * a22,

             b20 * a00 + b21 * a10 + b22 * a20,
             b20 * a01 + b21 * a11 + b22 * a21,
             b20 * a02 + b21 * a12 + b22 * a22
        ];
    }           
};

為了便於 3 x 3 矩陣轉為 applyMatrix 可用的引數,也寫了個 forApplyMatrix,先來看看怎麼使用,對於以下的範例:

可以改寫為以下:

這邊注意一下矩陣計算的順序,如〈認識矩陣〉中談到的,使用 translaterotateapplyMatrix 等操作時,基本的出發點就是,先畫出圖,再逐一疊加轉換操作,這邊在矩陣運算時,也採取與使用 p5.js 時的 translaterotate 的順序,結果再餵給 applyMatrix

來看看這個例子,使用 translaterotate 來完成不斷繪出變色的圓:

可以觀察到,在角度的變化方面是相同的,若建立可重複使用的引數給 applyMatrix,是否能改變效能呢?

當然,就這個範例來說,可能看不出什麼效能上的差異,這是個概念示範,若有些複雜的座標運算是重複的話,有時可以試著從矩陣運算來思考,看看那些複雜的運算是否可以重用。

另外,在〈座標處理〉中談過,轉換操作會累計,背後都是矩陣運算,其實每個轉換操作呼叫時,都相當於對內部管理的矩陣進行乘法運算,可以用以下程式來模擬一下: