跳轉到

8.4 電腦繪圖與齊次座標——從旋轉立方體到迷你渲染管線#

本節目標#

  • 寫出 3D 空間繞三軸的旋轉矩陣,並用 matplotlib 畫出旋轉的立方體
  • 看清一個關鍵限制:平移不是線性變換,不能用 \(3\times 3\) 矩陣表達
  • 學會齊次座標的技巧:升一維,讓平移、旋轉、縮放全部變成矩陣乘法
  • 理解透視投影矩陣:把 3D 世界壓扁成 2D 畫面
  • 實作一個迷你渲染管線:立方體 → 旋轉 → 平移 → 投影 → 畫面
  • 實驗:改參數產生動畫,存出多張圖

問題:遊戲畫面是怎麼來的?#

你在遊戲裡看到的每一幀,本質上都是一連串矩陣乘法: 模型轉個角度(旋轉矩陣)、放到場景某處(平移)、 再被攝影機拍下來(投影)。學完這一節,你會知道這條管線的全部數學。

3D 旋轉矩陣#

第 3 章學過 2D 旋轉。3D 中繞哪個軸轉,哪個座標就不動, 所以三個基本旋轉矩陣都是「一個 2D 旋轉 + 一個 1」:

\[ R_x(\theta) = \begin{bmatrix} 1 & 0 & 0 \\ 0 & \cos\theta & -\sin\theta \\ 0 & \sin\theta & \cos\theta \end{bmatrix}, \quad R_y(\theta) = \begin{bmatrix} \cos\theta & 0 & \sin\theta \\ 0 & 1 & 0 \\ -\sin\theta & 0 & \cos\theta \end{bmatrix}, \quad R_z(\theta) = \begin{bmatrix} \cos\theta & -\sin\theta & 0 \\ \sin\theta & \cos\theta & 0 \\ 0 & 0 & 1 \end{bmatrix} \]

先畫一個會轉的立方體。立方體只有 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 為例):

\[ \begin{bmatrix} 1 & 0 & t_x \\ 0 & 1 & t_y \\ 0 & 0 & 1 \end{bmatrix} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} = \begin{bmatrix} x + t_x \\ y + t_y \\ 1 \end{bmatrix} \]

3D 版本就是 \(4\times 4\) 矩陣:

\[ T(t_x, t_y, t_z) = \begin{bmatrix} 1 & 0 & 0 & t_x \\ 0 & 1 & 0 & t_y \\ 0 & 0 & 1 & t_z \\ 0 & 0 & 0 & 1 \end{bmatrix} \]

不只平移——所有變換都統一了:

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\) 上的位置:

\[ x' = \frac{d \cdot x}{-z}, \qquad y' = \frac{d \cdot y}{-z} \]

除法看起來又打破「矩陣乘法」的統一……但齊次座標再次救場: 我們允許齊次座標的最後一項不為 \(1\) ,最後再統一除掉(透视除法):

\[ P = \begin{bmatrix} d & 0 & 0 & 0 \\ 0 & d & 0 & 0 \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} \;\Rightarrow\; P\begin{bmatrix} x \\ y \\ z \\ 1 \end{bmatrix} = \begin{bmatrix} dx \\ dy \\ z \\ 1 \end{bmatrix} \xrightarrow{\div w} \cdots \]

實作上最簡單的透視投影:乘完矩陣後把前兩個分量除以 \(-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

動手實驗,觀察每個參數的效果:

  1. 把 d 從 \(2\) 改成 \(10\) :畫面整體放大—— \(d\) 相當於焦距
  2. 把 dist 改小到接近立方體半徑:透視畸變劇增(近大遠小被極端誇張)
  3. 把 project 改成直接取 (x, y)(丟棄 \(z\) ,正交投影): 遠近縮放效果消失——這就是工程製圖用的視角
  4. 組合 T @ Rx @ Ry 與 T @ Ry @ Rx 各畫一張,直觀感受旋轉不可交換

小結與練習#

本節重點

  • 3D 旋轉:繞哪軸、哪個座標不動;旋轉矩陣之間不可交換
  • 平移不是線性變換(破壞原點), \(3\times 3\) 矩陣無能為力
  • 齊次座標:升一維補一個 \(1\) ,平移、旋轉、縮放全部統一成矩陣乘法, 整條變換管線可以預乘成一個矩陣
  • 透視投影:除以深度產生「近大遠小」;齊次座標把除法留到最後統一處理
  • 渲染管線 = 頂點的一連串矩陣乘法:模型變換 → 世界變換 → 投影

練習

  1. 推導 \(R_y(\theta)\) :對照 \(R_x\) 、 \(R_z\) 的結構,說明為什麼它的負號位置不同。
  2. 寫出「繞 \(z\) 軸轉 \(30°\) 、再平移 \((1, 2, 0)\) 」的單一 \(4\times 4\) 矩陣, 並用頂點 \((1, 0, 0, 1)\) 手工驗算。
  3. 加入縮放動畫:讓立方體在動畫中脈動(S(s, s, s) 的 \(s\) 隨時間變化)。
  4. 挑戰題:實作一個「繞任意單位軸 \(\mathbf{u}\) 旋轉 \(\theta\) 」的矩陣 (Rodrigues 公式),並驗證它與 Rx @ Ry 組合的差異。
  5. 挑戰題:把管線反向——給定投影後的 2D 點與已知深度 \(z\) ,還原 3D 點。 哪些資訊在投影中永遠丟失了?