8.4 電腦繪圖與齊次座標——從旋轉立方體到迷你渲染管線#
本節目標#
- 寫出 3D 空間繞三軸的旋轉矩陣,並用 matplotlib 畫出旋轉的立方體
- 看清一個關鍵限制:平移不是線性變換,不能用 \(3\times 3\) 矩陣表達
- 學會齊次座標的技巧:升一維,讓平移、旋轉、縮放全部變成矩陣乘法
- 理解透視投影矩陣:把 3D 世界壓扁成 2D 畫面
- 實作一個迷你渲染管線:立方體 → 旋轉 → 平移 → 投影 → 畫面
- 實驗:改參數產生動畫,存出多張圖
問題:遊戲畫面是怎麼來的?#
你在遊戲裡看到的每一幀,本質上都是一連串矩陣乘法: 模型轉個角度(旋轉矩陣)、放到場景某處(平移)、 再被攝影機拍下來(投影)。學完這一節,你會知道這條管線的全部數學。
3D 旋轉矩陣#
第 3 章學過 2D 旋轉。3D 中繞哪個軸轉,哪個座標就不動, 所以三個基本旋轉矩陣都是「一個 2D 旋轉 + 一個 1」:
先畫一個會轉的立方體。立方體只有 8 個頂點和 12 條邊:
import numpy as np
import matplotlib.pyplot as plt
# 8 個頂點(每一「行」是一個齊次前的 3D 點)
verts = np.array([[x, y, z] for x in (-1, 1)
for y in (-1, 1)
for z in (-1, 1)], dtype=float).T # (3, 8)
edges = [(0,1),(0,2),(2,3),(1,3), # 底面
(4,5),(4,6),(6,7),(5,7), # 頂面
(0,4),(1,5),(2,6),(3,7)] # 立柱
def Rx(t): return np.array([[1,0,0],[0,np.cos(t),-np.sin(t)],[0,np.sin(t),np.cos(t)]])
def Ry(t): return np.array([[np.cos(t),0,np.sin(t)],[0,1,0],[-np.sin(t),0,np.cos(t)]])
def Rz(t): return np.array([[np.cos(t),-np.sin(t),0],[np.sin(t),np.cos(t),0],[0,0,1]])
fig = plt.figure(figsize=(5, 5))
ax = fig.add_subplot(projection="3d")
V = Rx(np.pi/6) @ Ry(np.pi/4) @ verts # 先繞 y 再繞 x
for i, j in edges:
ax.plot(*V[:, [i, j]], c="tab:blue")
ax.scatter(*V, c="tab:red", s=20)
ax.set_box_aspect([1, 1, 1])
ax.set_title("旋轉後的立方體")
plt.savefig("cube_rotated.png", dpi=120); plt.show()
注意組合順序:Rx @ Ry @ verts 是**先轉 \(y\) 、再轉 \(x\) ** (矩陣從右往左作用),順序不同結果不同——3D 旋轉不可交換。
平移不是線性變換!#
現在想把立方體移到 \((t_x, t_y, t_z)\) 。你會發現一個根本性的障礙:
平移 \(\mathbf{v} \mapsto \mathbf{v} + \mathbf{t}\) 不保持原點, 所以它不是線性變換,不可能有任何矩陣 \(T\) 使 \(T\mathbf{v} = \mathbf{v} + \mathbf{t}\) 。
驗證這件事只要一行:
t = np.array([2.0, 1.0, -3.0])
v = np.zeros(3)
# 假設存在矩陣 T 滿足 T @ v = v + t
# 但任何矩陣乘零向量都得到零向量,而 v + t = t ≠ 0,矛盾!
print("任何矩陣都無法表達平移:T @ 0 = 0,但 0 + t =", t)
但遊戲引擎需要把旋轉、縮放、平移串成一條管線, 如果平移要特殊處理(用加法),整條管線就斷了。怎麼辦?
齊次座標:升一維的魔法#
齊次座標(homogeneous coordinates)的技巧: 給每個點多加一個座標 \(1\) 。2D 點 \((x, y)\) 變成 \([x, y, 1]^T\) , 3D 點 \((x, y, z)\) 變成 \([x, y, z, 1]^T\) 。
在升維後的空間裡,平移奇蹟般地變成矩陣乘法(以 2D 為例):
3D 版本就是 \(4\times 4\) 矩陣:
不只平移——所有變換都統一了:
def T(tx, ty, tz):
M = np.eye(4); M[:3, 3] = [tx, ty, tz]; return M
def S(sx, sy, sz):
M = np.eye(4); M[0,0], M[1,1], M[2,2] = sx, sy, sz; return M
def H(R): # 把 3x3 旋轉嵌入 4x4
M = np.eye(4); M[:3, :3] = R; return M
# 完整變換 = 一個 4x4 矩陣:先縮放、再旋轉、最後平移
M_full = T(2, 1, 0) @ H(Ry(np.pi/4)) @ S(1.5, 1.5, 1.5)
p = np.array([1.0, 0.0, 0.0, 1.0]) # 齊次座標的點
print("變換後:", (M_full @ p)[:3]) # [3.06 1.06 0.71]
print("一切變換都只是矩陣乘法!")
幾個立刻到手的紅利:
- 串接: \(n\) 個變換串成一個矩陣,只需預先乘好 \(M = T_1 T_2 \cdots T_n\) , 之後每個頂點一次乘法搞定——這就是遊戲引擎的 model matrix
- 逆變換:想「反悔」某個變換? \(M^{-1}\) 就好(
np.linalg.inv) - 仿射變換的統一理論:仿射變換 \(=\) 線性變換 \(+\) 平移, 在齊次座標下全部是(升維後的)線性變換
投影矩陣:3D 壓成 2D#
最後一步:攝影機拍攝。透視投影模擬真實眼睛—— 遠的東西小、近的東西大。設攝影機在原點沿 \(-z\) 方向看, 一點 \((x, y, z)\) 投影到平面 \(z = -d\) 上的位置:
除法看起來又打破「矩陣乘法」的統一……但齊次座標再次救場: 我們允許齊次座標的最後一項不為 \(1\) ,最後再統一除掉(透视除法):
實作上最簡單的透視投影:乘完矩陣後把前兩個分量除以 \(-z\) :
def project(V, d=2.0):
"""V: (3, n) 3D 點 → (2, n) 螢幕座標(透視投影)"""
x, y, z = V
depth = -z
assert (depth > 0.1).all(), "點必須在攝影機前方!"
return np.vstack([d * x / depth, d * y / depth])
迷你渲染管線:把一切串起來#
組裝完整管線:立方體 → 旋轉 → 平移到遠方 → 透視投影 → 2D 畫面:
def render(V3, yaw, pitch, dist, d=2.0, ax=None):
"""V3: (3,8) 頂點;回傳 (2,8) 螢幕座標"""
R = H(Ry(yaw)) @ H(Rx(pitch)) # 模型旋轉
M = T(0, 0, -dist) @ R # 推到攝影機前方
Vh = np.vstack([V3, np.ones(V3.shape[1])]) # 升成齊次座標
Vt = M @ Vh # 世界變換
xy = project(Vt[:3]) # 投影成 2D
if ax is not None:
for i, j in edges:
ax.plot(*xy[:, [i, j]], c="tab:blue", lw=1.5)
ax.scatter(*xy, c="tab:red", s=25)
ax.set_aspect("equal"); ax.axis("off")
return xy
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
for ax, dist in zip(axes, [3.0, 5.0, 9.0]):
render(verts, yaw=np.pi/5, pitch=np.pi/7, dist=dist, ax=ax)
ax.set_title(f"距離 = {dist}")
plt.suptitle("透視投影:同一個立方體,越遠越小")
plt.savefig("perspective.png", dpi=120); plt.show()
三張圖中立方體隨距離縮小——這不是畫圖技巧,而是投影矩陣裡除以 \(-z\) 的直接結果。 每一幀的計算量:8 個頂點 × 一次 \(4\times 4\) 乘法 + 一次除法。 真實遊戲有百萬頂點,但數學一模一樣,剩下的全是並行化問題。
實驗:改參數做動畫#
把參數變成時間的函數,逐幀存圖,就能做出動畫素材:
import os
os.makedirs("frames", exist_ok=True)
n_frames = 30
for f in range(n_frames):
t = f / n_frames
yaw = 2 * np.pi * t # 繞 y 轉一整圈
pitch = np.pi / 6 * (1 + np.sin(2 * np.pi * t)) # 俯仰角擺動
dist = 4 + 2 * np.sin(2 * np.pi * t) # 遠近伸縮
fig = plt.figure(figsize=(4, 4))
ax = fig.add_subplot()
render(verts, yaw, pitch, dist, ax=ax)
ax.set_xlim(-1.2, 1.2); ax.set_ylim(-1.2, 1.2)
ax.set_title(f"frame {f:02d}")
fig.savefig(f"frames/f{f:02d}.png", dpi=80)
plt.close(fig) # 記得關閉,避免記憶體爆炸
print("已輸出", n_frames, "張影格到 frames/")
# 用 ffmpeg 合成動畫(在終端執行):
# ffmpeg -framerate 15 -i frames/f%02d.png -pix_fmt yuv420p cube.mp4
動手實驗,觀察每個參數的效果:
- 把
d從 \(2\) 改成 \(10\) :畫面整體放大—— \(d\) 相當於焦距 - 把
dist改小到接近立方體半徑:透視畸變劇增(近大遠小被極端誇張) - 把
project改成直接取(x, y)(丟棄 \(z\) ,正交投影): 遠近縮放效果消失——這就是工程製圖用的視角 - 組合
T @ Rx @ Ry與T @ Ry @ Rx各畫一張,直觀感受旋轉不可交換
小結與練習#
本節重點
- 3D 旋轉:繞哪軸、哪個座標不動;旋轉矩陣之間不可交換
- 平移不是線性變換(破壞原點), \(3\times 3\) 矩陣無能為力
- 齊次座標:升一維補一個 \(1\) ,平移、旋轉、縮放全部統一成矩陣乘法, 整條變換管線可以預乘成一個矩陣
- 透視投影:除以深度產生「近大遠小」;齊次座標把除法留到最後統一處理
- 渲染管線 = 頂點的一連串矩陣乘法:模型變換 → 世界變換 → 投影
練習
- 推導 \(R_y(\theta)\) :對照 \(R_x\) 、 \(R_z\) 的結構,說明為什麼它的負號位置不同。
- 寫出「繞 \(z\) 軸轉 \(30°\) 、再平移 \((1, 2, 0)\) 」的單一 \(4\times 4\) 矩陣, 並用頂點 \((1, 0, 0, 1)\) 手工驗算。
- 加入縮放動畫:讓立方體在動畫中脈動(
S(s, s, s)的 \(s\) 隨時間變化)。 - 挑戰題:實作一個「繞任意單位軸 \(\mathbf{u}\) 旋轉 \(\theta\) 」的矩陣 (Rodrigues 公式),並驗證它與
Rx @ Ry組合的差異。 - 挑戰題:把管線反向——給定投影後的 2D 點與已知深度 \(z\) ,還原 3D 點。 哪些資訊在投影中永遠丟失了?