前言
我第一次真正「看懂」矩陣的時候,不是在線性代數的課堂上,而是在寫 shader 的某個深夜。我把一個 2×2 矩陣的四個數字接上滑桿,然後拖動它們,看著螢幕上的正方形被拉伸、旋轉、扭曲——突然間,矩陣不再是一張數字表格,而是一種「空間的變形」。
這篇文章想帶你建立同樣的直覺。我們會從最基本的 2D 變換矩陣開始,逐步推導旋轉、縮放、剪切的矩陣形式,並用 p5.js 把每一種變換的幾何意義動態展示出來。
矩陣是什麼?一種空間變換
在 2D 平面上,一個點 (x, y) 可以被一個 2×2 矩陣變換成新的點 (x’, y’):
| x' | | a b | | x | | ax + by |
| | = | | | | = | |
| y' | | c d | | y | | cx + dy |
這意味著:矩陣的第一列 (a, c) 決定了原本的 x 軸基向量被映射到哪裡,第二列 (b, d) 決定了原本的 y 軸基向量被映射到哪裡。
這個觀點非常重要。矩陣本質上就是在告訴你:原來的兩個基向量 (1,0) 和 (0,1) 被送到了哪裡。 理解了這一點,所有的變換矩陣都變得直覺了。
基本變換
縮放(Scaling)
最簡單的變換。把 x 方向放大 sx 倍,y 方向放大 sy 倍:
| sx 0 |
| 0 sy |
基向量 (1,0) 被映射到 (sx, 0),(0,1) 被映射到 (0, sy)。就像抓住一張圖片的邊角拉伸一樣。
當 sx 或 sy 為負數時,就是鏡射(flip)。例如 sx = -1, sy = 1 就是水平翻轉。
旋轉(Rotation)
這是最經典的推導。我們要把空間逆時針旋轉 θ 角。問自己:基向量 (1,0) 旋轉 θ 之後到哪了?答案是 (cos θ, sin θ)。那 (0,1) 旋轉 θ 之後呢?答案是 (-sin θ, cos θ)。
所以旋轉矩陣就是:
| cos θ -sin θ |
| sin θ cos θ |
就是這樣!不需要背誦,只需要想一想基向量被送到哪裡就好了。
剪切(Shear)
剪切是一種比較少被注意但很有趣的變換。水平剪切的矩陣:
| 1 k |
| 0 1 |
這讓 (1,0) 不動,但 (0,1) 被映射到 (k, 1)。效果就像把一疊撲克牌推歪——每一層水平偏移的量和它的高度成正比。
垂直剪切則是:
| 1 0 |
| k 1 |
p5.js 視覺化:觀察基向量的變化
以下是一個完整的程式,讓你用滑桿控制 2×2 矩陣的四個元素,即時觀察變換效果:
let sliders = [];
function setup() {
createCanvas(600, 600);
// 矩陣的四個元素 [a, b, c, d]
// 初始值為單位矩陣
let defaults = [1, 0, 0, 1];
let labels = ['a', 'b', 'c', 'd'];
for (let i = 0; i < 4; i++) {
sliders[i] = createSlider(-200, 200, defaults[i] * 100);
sliders[i].position(20, 610 + i * 30);
// 標籤文字可以用 createSpan 等方式加上
}
}
function draw() {
background(30);
translate(width / 2, height / 2);
scale(1, -1); // 讓 y 軸朝上
let a = sliders[0].value() / 100;
let b = sliders[1].value() / 100;
let c = sliders[2].value() / 100;
let d = sliders[3].value() / 100;
// 畫網格(變換前,淡灰色)
stroke(60);
strokeWeight(0.5);
for (let i = -5; i <= 5; i++) {
line(i 50, -300, i 50, 300);
line(-300, i 50, 300, i 50);
}
// 畫變換後的網格
stroke(80, 80, 80, 120);
strokeWeight(1);
for (let i = -5; i <= 5; i++) {
// 垂直線:x=i, y 從 -5 到 5
let x1 = (a i 50 + b (-5) 50);
let y1 = (c i 50 + d (-5) 50);
let x2 = (a i 50 + b 5 50);
let y2 = (c i 50 + d 5 50);
line(x1, y1, x2, y2);
// 水平線:y=i, x 從 -5 到 5
x1 = (a (-5) 50 + b i 50);
y1 = (c (-5) 50 + d i 50);
x2 = (a 5 50 + b i 50);
y2 = (c 5 50 + d i 50);
line(x1, y1, x2, y2);
}
// 畫變換後的基向量
let scale_ = 100;
// e1 = (a, c)
stroke(255, 80, 80);
strokeWeight(3);
line(0, 0, a scale_, c scale_);
drawArrow(a scale_, c scale_, 255, 80, 80);
// e2 = (b, d)
stroke(80, 180, 255);
line(0, 0, b scale_, d scale_);
drawArrow(b scale_, d scale_, 80, 180, 255);
// 畫變換後的單位正方形
noFill();
stroke(255, 200, 0, 180);
strokeWeight(2);
beginShape();
vertex(0, 0);
vertex(a scale_, c scale_);
vertex((a + b) scale_, (c + d) scale_);
vertex(b scale_, d scale_);
endShape(CLOSE);
// 原點
fill(255);
noStroke();
ellipse(0, 0, 8, 8);
// 顯示矩陣值(需要翻轉文字)
scale(1, -1);
fill(255);
textSize(14);
text(| ${nf(a,1,2)} ${nf(b,1,2)} |, -280, -260);
text(| ${nf(c,1,2)} ${nf(d,1,2)} |, -280, -240);
text(det = ${nf(a<em>d - b</em>c, 1, 3)}, -280, -210);
}
function drawArrow(x, y, r, g, b) {
fill(r, g, b);
noStroke();
push();
translate(x, y);
let angle = atan2(y, x);
rotate(angle);
triangle(0, 0, -12, 6, -12, -6);
pop();
}
拖動滑桿時,你會看到:
- 紅色箭頭是第一個基向量 (a, c) 的位置
- 藍色箭頭是第二個基向量 (b, d) 的位置
- 黃色四邊形是原本的單位正方形被變換後的形狀
- 背景網格也跟著變形
旋轉矩陣的推導:用動畫理解
讓我們做一個旋轉矩陣的動畫驗證:
let angle = 0;
function setup() {
createCanvas(500, 500);
}
function draw() {
background(30);
translate(width / 2, height / 2);
scale(1, -1);
// 原始正方形的頂點
let points = [
{ x: 50, y: 50 },
{ x: -50, y: 50 },
{ x: -50, y: -50 },
{ x: 50, y: -50 }
];
// 旋轉矩陣
let cosA = cos(angle);
let sinA = sin(angle);
// 方法一:手動矩陣乘法
let transformed = points.map(p => ({
x: cosA p.x - sinA p.y,
y: sinA p.x + cosA p.y
}));
// 畫原始正方形(半透明)
noFill();
stroke(100);
beginShape();
for (let p of points) vertex(p.x, p.y);
endShape(CLOSE);
// 畫旋轉後的正方形
stroke(255, 80, 80);
strokeWeight(2);
beginShape();
for (let p of transformed) vertex(p.x, p.y);
endShape(CLOSE);
// 畫基向量
let scale_ = 80;
stroke(255, 80, 80);
line(0, 0, cosA scale_, sinA scale_);
stroke(80, 180, 255);
line(0, 0, -sinA scale_, cosA scale_);
// 顯示角度
scale(1, -1);
fill(255);
textSize(14);
text("θ = " + nf(degrees(angle), 1, 1) + "°", -230, -220);
angle += 0.01;
}
複合變換:順序很重要
矩陣的一個關鍵特性:乘法不可交換。也就是說,先旋轉再縮放和先縮放再旋轉,結果通常不同。
// 先縮放再旋轉
M1 = R * S
// 先旋轉再縮放
M2 = S * R
// 一般來說 M1 ≠ M2
在程式碼中,變換的套用順序是「後寫的先執行」。這在 p5.js 中非常直覺:
// p5.js 的變換是從下往上讀的
translate(200, 200); // 第三步:平移到畫布中心
rotate(angle); // 第二步:旋轉
scale(2, 1); // 第一步:x 方向拉伸
rect(-25, -25, 50, 50); // 畫一個正方形
如果你交換 rotate 和 scale 的順序,結果會完全不同。先拉伸再旋轉,你會得到一個旋轉的長方形;先旋轉再拉伸,你會得到一個傾斜的平行四邊形。
applyMatrix:完全掌控
p5.js 提供了 applyMatrix() 函數,讓你直接套用一個 3×3 的仿射變換矩陣(齊次座標)。這在某些情況下比 translate/rotate/scale 更方便。
仿射變換矩陣包含了平移,形式如下:
| a b tx |
| c d ty |
| 0 0 1 |
在 p5.js 中使用:
function draw() {
background(30);
let angle = frameCount * 0.02;
let cosA = cos(angle);
let sinA = sin(angle);
// 平移到畫面中心 + 旋轉
// applyMatrix(a, b, c, d, tx, ty) — 注意 p5.js 的參數順序
applyMatrix(cosA, sinA, -sinA, cosA, width/2, height/2);
// 此時的座標系統已經被旋轉並平移了
fill(255, 80, 80);
noStroke();
rectMode(CENTER);
rect(0, 0, 100, 100);
// 繪製子物件(局部座標系統)
fill(80, 180, 255);
rect(80, 0, 30, 30);
}
利用矩陣做骨骼動畫
複合變換最經典的應用就是骨骼動畫。每個關節有自己的局部旋轉,透過矩陣串聯就能算出末端的全域位置:
function draw() {
background(30);
translate(width / 2, height / 2);
let angle1 = sin(frameCount 0.03) 0.5; // 上臂擺動
let angle2 = sin(frameCount 0.05) 0.8; // 前臂擺動
let angle3 = sin(frameCount 0.07) 0.4; // 手掌擺動
// 上臂
rotate(angle1);
stroke(255, 80, 80);
strokeWeight(8);
line(0, 0, 100, 0);
// 前臂 — 從上臂末端開始
translate(100, 0);
rotate(angle2);
stroke(80, 180, 255);
line(0, 0, 80, 0);
// 手掌
translate(80, 0);
rotate(angle3);
stroke(255, 200, 0);
line(0, 0, 40, 0);
// 手掌末端的圓點
translate(40, 0);
fill(255);
noStroke();
ellipse(0, 0, 12, 12);
}
每一次的 translate + rotate 都相當於乘上一個新的局部變換矩陣。p5.js 會自動把所有變換累積起來,算出每個物件在全域座標中的位置。
行列式:面積的變化量
矩陣有一個重要的數值叫做行列式(determinant):
det(M) = ad - bc
行列式的絕對值代表面積的縮放比例。如果原本的單位正方形面積是 1,變換後的面積就是 |det(M)|。
- det = 1:面積不變(旋轉就是這類)
- det > 1:面積放大
- det < 1:面積縮小
- det < 0:空間被翻轉(鏡射),方向反了
- det = 0:空間被壓扁到一條線或一個點(不可逆)
小結
矩陣變換是計算機圖學的基石。從 2D 的 CSS transform,到 3D 的 MVP 矩陣,到機器學習中的張量運算,矩陣無處不在。而理解它們的最好方式,就是「看」它們對空間做了什麼事。
記住這個核心觀點:矩陣的列就是基向量的新位置。 掌握了這個直覺,任何變換矩陣你都能一眼看懂。
延伸閱讀
- 3Blue1Brown 的「Essence of Linear Algebra」系列 — 這是我看過最好的矩陣視覺化教學
- The Book of Shaders 第二章(矩陣)
- p5.js 官方文檔中的
applyMatrix()說明 - 試著把本文的 2D 矩陣擴展到 3D(3×3 矩陣),觀察旋轉在三維空間中的行為