/ Ch08 视图与投影变换

第8章:视图与投影变换 — 零基础讲义

讲义说明
本讲义基于 Steve Marschner & Peter Shirley 所著《虎书5》第5版第8章(p.157-169)。
本章是 3D 图形学的"心脏"——它回答一个根本问题:3D 世界里的物体,如何变成 2D 屏幕上的像素?
学完本章你将掌握视图变换、投影变换、视口变换这"三把钥匙",真正理解 3D 渲染管线的本质。
本版插画采用 Guizang 材质插画风格重新绘制。

学习目标

  1. 理解视图变换的本质——一个"变换链"将物体从自身空间映射到屏幕
  2. 掌握正交投影——保持平行线的工程/制图投影
  3. 掌握透视投影——模拟人眼"远小近大"的效果
  4. 理解齐次坐标——它不只能区分"位置 vs 方向",还能表达"无穷远"
  5. 理解透视除法的几何含义——为什么 w = z 是"魔法"


8.1 视图变换:从世界到屏幕的"四空间"旅程

8.1.1 为什么需要视图变换?

问题:你有一个 3D 场景,里面有房子、树木、天空。你想看到这个场景——就像你站在某个位置用相机拍照一样。

但计算机的屏幕是 2D 的。所以我们需要一个过程,把 3D 场景"拍"到 2D 平面上。这个过程就是视图变换

生活类比:拍一张照片
你举起手机拍一张照片,背后发生了三件事:
1. 你站在某个位置,看向某个方向(相机变换
2. 光线通过镜头,把 3D 景物投射到感光元件上(投影变换
3. 感光元件把光信号转换成像素(视口变换

整个视图变换过程让物体在 4 个空间之间"流动":

物体空间(Object) → 世界空间(World) → 相机空间(Camera) → 屏幕空间(Screen)
   ↑建模变换           ↑相机变换           ↑投影变换          ↑视口变换
核心洞察:理解渲染管线 = 理解这 4 个空间 + 4 个变换。每个变换只做一件事——把物体从一个坐标系"翻译"到另一个坐标系
空间含义类比
物体空间物体自身的坐标系,原点通常在物体中心你手里拿着的模型
世界空间场景的绝对坐标系,所有物体共享房间里的全局位置
相机空间以相机为原点的坐标系你眼睛看到的世界
屏幕空间最终像素坐标 [0, width] × [0, height]照片上的位置
🤔 想一想:如果跳过"世界空间",直接从物体空间到相机空间行不行?
技术上可以——把建模矩阵和相机矩阵合并。但这样每个物体都需要独立的"合并矩阵",场景中有100个物体就需要算100次。分开的好处是:世界矩阵只需算一次(相机的位置是固定的),每个物体的建模矩阵独立计算。这就是"分而治之"的思想。

8.1.2 视图变换的三个子变换

视图变换由三个子变换组成:

M_view = M_vp × M_proj × M_cam
子变换作用输入→输出依赖参数
相机变换 M_cam世界坐标 → 相机坐标p_world → p_camera相机位置 e、朝向 g、向上方向 t
投影变换 M_proj相机坐标 → 规范化设备坐标p_camera → p_ndc投影类型(正交/透视)、近远平面
视口变换 M_vp规范化坐标 → 像素坐标[-1,1]² → [0,width]×[0,height]屏幕分辨率 nₓ, nᵧ

渲染每条线段时的伪代码:

// 构造三个矩阵
construct M_vp        // 视口矩阵:取决于屏幕大小
construct M_orth      // 投影矩阵:正交或透视
construct M_cam       // 相机矩阵:取决于相机位置

// 合并为总变换矩阵
M = M_vp × M_orth × M_cam

// 对每个线段进行变换和绘制
for each line segment (aᵢ, bᵢ) do
    p = M × aᵢ        // 变换线段端点
    q = M × bᵢ
    drawline(x_p, y_p, x_q, y_q)  // 在屏幕上画线

8.1.3 视口变换(Viewport Transform)

为什么需要视口变换? 规范化设备坐标(NDC)的范围是 [-1, 1]²,但屏幕像素坐标是 [0, nₓ] × [0, nᵧ]。我们需要把 -1~1 映射到 0~宽/高。

公式

M_vp = [nₓ/2, 0, (nₓ-1)/2; 0, nᵧ/2, (nᵧ-1)/2; 0, 0, 1]

公式拆解

矩阵元素含义
(0, 0)nₓ / 2把 [-1, 1] 的 x 范围缩放到 [0, nₓ]
(1, 1)nᵧ / 2把 [-1, 1] 的 y 范围缩放到 [0, nᵧ]
(0, 2)(nₓ - 1) / 2把缩放后的中心移到像素坐标原点
(1, 2)(nᵧ - 1) / 2同上,y 方向
重要洞察"(nₓ-1)/2 中的 -1 是因为像素中心在 (0, 0) 到 (nₓ-1, nᵧ-1)——最后一个像素的中心是 (nₓ-1, nᵧ-1),不是 (nₓ, nᵧ)。这是从第3章学到的"半个像素 overshoot"约定。

数值例子:假设屏幕分辨率是 1920 × 1080:

M_vp = [960, 0, 959.5]
       [0, 540, 539.5]
       [0, 0, 1]

对 NDC 点 (0, 0):
  x_screen = 960 × 0 + 959.5 = 959.5 → 接近屏幕中心
对 NDC 点 (1, 1)(最右上角):
  x_screen = 960 × 1 + 959.5 = 1919.5 → 最后一个像素中心
  y_screen = 540 × 1 + 539.5 = 1079.5 → 最后一行像素中心
🤔 想一想:视口矩阵还保留了 z 坐标(最后一行 [0, 0, 1]),为什么?
为了深度测试(z-buffer)。GPU 需要知道每个像素的深度值,才能判断哪个物体更近。视口变换只是"传递"z 坐标,不做缩放——因为投影变换已经处理了深度映射。

8.1.4 规范化视体(Canonical View Volume)

问题:不同的投影方式(正交、透视)产生不同的"可视区域"。为了让 GPU 统一处理,我们需要一个标准化的参考系

正交投影映射到规范化立方体
图8-2:正交投影把长方体映射到规范化立方体 [-1, 1]³

规范化视体:边长为 2 的立方体,中心在原点,所有可见点的坐标范围:

[-1, 1] × [-1, 1] × [-1, 1]

为什么用 [-1, 1]³ 而不是其他范围?

原因解释
对称性中心在原点,正负对称,便于公式推导
GPU 原生支持大多数图形硬件(OpenGL)期望 NDC 在 [-1, 1]
统一接口正交/透视投影都"收敛"到同一个立方体,光栅化阶段无需区分
重要洞察:规范化视体是投影变换的"输出格式"。无论输入是什么形状的视锥体,输出都是 [-1, 1]³。这相当于把所有可能的"相机镜头"都翻译成 GPU 能理解的"通用语言"。

8.1.5 正交投影(Orthographic Projection)

为什么需要正交投影? 有些情况下,我们不想要"远小近大"的透视效果。例如工程图纸、2D 游戏、UI 界面——我们需要保持物体的真实比例

生活类比:在 CAD 软件中看一个零件的三视图——从正面看过去,零件的前面和后面一样大。这就是正交投影。

问题:我们的"可视区域"是一个任意长方体 [l, r] × [b, t] × [f, n]。需要把它映射到规范化立方体 [-1, 1]³。

公式

M_orth = [2/(r-l), 0, 0, -(r+l)/(r-l); 0, 2/(t-b), 0, -(t+b)/(t-b); 0, 0, 2/(n-f), (n+f)/(n-f); 0, 0, 0, 1]

逐行拆解

操作解释
第1行x' = 2x/(r-l) - (r+l)/(r-l)把 x 从 [l, r] 缩放到 [-1, 1],中心移到 0
第2行y' = 2y/(t-b) - (t+b)/(t-b)把 y 从 [b, t] 缩放到 [-1, 1]
第3行z' = 2z/(n-f) + (n+f)/(n-f)把 z 从 [f, n] 缩放到 [-1, 1]

注意第三行:因为 z 轴方向是 near = 大 z,far = 小 z(往屏幕里看是 -z 方向),所以分母是 (n-f)。

数值例子:假设视体是 l=-2, r=2, b=-1.5, t=1.5, f=-10, n=-1:

M_orth = [2/(2-(-2))=0.5, 0, 0, -(2+(-2))/(2-(-2))=0]
         [0, 2/(1.5-(-1.5))=0.667, 0, -(1.5+(-1.5))/(1.5-(-1.5))=0]
         [0, 0, 2/(-1-(-10))=0.222, (-1+(-10))/(-1-(-10))=-1.222]
         [0, 0, 0, 1]

对点 (2, 1.5, -10)(视体右上后角):
  x' = 0.5 × 2 + 0 = 1          ← 映射到 NDC 最右边
  y' = 0.667 × 1.5 + 0 = 1      ← 映射到 NDC 最上边
  z' = 0.222 × (-10) + (-1.222) = -1  ← 映射到 NDC 最后面
重要洞察:正交投影是仿射变换(4×4 矩阵最后一行 [0,0,0,1])。这意味着它保持平行线——两条平行线在正交投影后仍然平行。没有"汇聚"效果。这正是工程制图所需要的。
🤔 想一想:正交投影不改变物体的大小。一个离相机 1 米的球和一个离相机 100 米的球在屏幕上一模一样大。这符合真实世界的视觉效果吗?
不符合!人眼看远处的物体就是会变小。所以正交投影只用于特定场景:工程制图、2D 游戏、UI 渲染、以及某些科学可视化。真实感渲染需要用透视投影

正交投影的应用场景

  • 工程图纸(CAD)——保持比例,精确测量
  • 2D 游戏——精灵图直接显示,不需要透视
  • UI 渲染——界面层永远是正交投影
  • 阴影贴图(Shadow Map)——有些光源用正交投影生成阴影

8.1.6 任意 3D 视图(相机变换 uv w 坐标系)

问题:上面的正交投影假定相机在原点看向 -z 方向。但真实场景中,相机可以在任意位置看向任意方向。

相机坐标系(u,v,w)
图8-3:相机坐标系——用 u、v、w 三个正交基向量定义相机的方向

解决:构造一个相机坐标系(uvw)——以相机位置为原点,三个正交轴:

  • w = 相机朝向的反方向(指向场景)
  • u = 相机的"右侧"方向
  • v = 相机的"上方向"

构造算法

// 输入:相机位置 e,视线方向 g,向上向量 t
// 输出:三个正交基向量 u, v, w

w = -g / ||g||              // w 指向场景(视线反方向)
                            // 除以模长使其成为单位向量

u = (t × w) / ||t × w||    // u = up × w,即"右侧"
                            // 叉积保证 u 垂直于 w

v = w × u                   // v = w × u,即"上方向"
                            // 保证 v 垂直于 u 和 w(右手系)

公式拆解

符号含义数值例子
e相机在世界中的位置e = (0, 2, 5)——相机在 Y=2 高度,Z=5 的位置
g视线方向(从 e 指向场景)g = (0, 0, -1)——看向 -z 方向
t世界"上方向"向量t = (0, 1, 0)——Y 轴向上
w归一化视线反方向w = (0, 0, 1)——看向 +z(但在相机坐标系是 -z)
u右方向u = t × w = (1, 0, 0)
v上方向v = w × u = (0, 1, 0)

相机变换矩阵

M_cam = [u.x, u.y, u.z, -e·u; v.x, v.y, v.z, -e·v; w.x, w.y, w.z, -e·w; 0, 0, 0, 1]

矩阵结构解读

部分内容含义
左上 3×3u, v, w 三个行向量旋转——把世界坐标旋转到相机朝向
右上 3×1-e·u, -e·v, -e·w平移——把世界原点移到相机位置
最后一行(0, 0, 0, 1)标准齐次坐标——说明这是仿射变换
// 数值例子:相机在 (0, 2, 5),看向 (0, 0, -1),Up=(0,1,0)
// 计算过程
w = (0, 0, 1)                              // g=(0,0,-1) 取反
u = (0,1,0) × (0,0,1) / ||...|| = (1,0,0)  // t × w
v = (0,0,1) × (1,0,0) = (0,1,0)           // w × u

// 平移部分
-e·u = -(0,2,5)·(1,0,0) = -0                // x 方向无需平移
-e·v = -(0,2,5)·(0,1,0) = -2                // y 方向下移 2
-e·w = -(0,2,5)·(0,0,1) = -5                // z 方向后退 5

// 完整矩阵
M_cam = [1, 0, 0,  0]
        [0, 1, 0, -2]
        [0, 0, 1, -5]
        [0, 0, 0,  1]

// 验证:世界原点 (0,0,0) → 相机坐标 (0, -2, -5)
M_cam × (0,0,0,1)^T = (0, -2, -5, 1)^T    ✓ 相机"看到"原点在下方 2 单位,前方 5 单位

8.2 投影变换(Projective Transformations)

8.2.1 为什么需要透视投影?

为什么正交投影不够? 正交投影保持了平行线和比例,但真实世界不是这样的。你看远处的山——它看起来比近处的树还小。这种"远小近大"的效果就是透视

针孔相机模型
图8-4:针孔相机模型——光线穿过针孔,在成像平面形成倒像。这是透视投影的物理基础

针孔相机模型是最简单的透视模型:

  • 所有光线都通过一个"点"(针孔 = 相机位置)
  • 光线在成像平面上投影
  • 物体越远,投影越小(因为光线角度变小)

核心公式(透视投影的基本形式):

y_screen = (n / z) × y

公式拆解

  • y_screen:屏幕上看到的 y 坐标
  • y:物体在 3D 空间中的实际 y 坐标
  • n:近平面距离(相机到成像平面的距离)
  • z:物体到相机的深度(z 坐标)
  • n / z:缩放因子——z 越大,缩放因子越小
🤔 想一想:z 在分母上意味着什么?
透视投影不是线性变换!仿射变换(如旋转、缩放、平移)都是线性的——可以写成 4×4 矩阵乘法,最后一行为 [0,0,0,1]。但透视投影需要除以 z,这不是矩阵乘法能直接表达的。这就是为什么我们需要齐次坐标

仿射变换 vs 投影变换

类型形式矩阵最后一行例子
仿射变换x' = ax + by + cz + d[0, 0, 0, 1]旋转、平移、缩放、正交投影
投影变换x' = (ax+by+cz+d) / (ex+fy+gz+h)不全为 [0,0,0,1]透视投影

8.2.2 齐次坐标的"魔法"

问题:透视投影需要除以 z,但标准 4×4 矩阵没法做除法。怎么办?

解决:用齐次坐标(homogeneous coordinates)。

核心思想:在 4D 空间中,向量 (x, y, z, w) 和 (kx, ky, kz, kw) 是等价的(对任何 k ≠ 0)。

(x, y, z, w) ≡ (x/w, y/w, z/w) (当 w ≠ 0)

这意味着什么? 我们可以先计算 4D 齐次结果,最后"除以 w"得到真正的 3D 坐标。这个除以 w 的操作称为透视除法(perspective division)。

数值例子

3D 坐标 (2, 3, 4) 的齐次表示可以是:
  (2, 3, 4, 1)    ← w=1,最常用的表示
  (4, 6, 8, 2)    ← w=2,等价于 (4/2, 6/2, 8/2) = (2, 3, 4)
  (0.2, 0.3, 0.4, 0.1)  ← w=0.1,等价

// 透视投影的妙用:我们在矩阵中让 w = z
P × (x, y, z, 1)^T = (nx, ny, (n+f)z - fn, z)^T
// 透视除法后:
(x', y', z') = (nx/z, ny/z, (n+f) - fn/z)
重要洞察:齐次坐标 (x,y,z,w) 的几何意义是:它在 4D 空间中是一条过原点的直线。这条直线与 w=1 超平面的交点就是对应的 3D 点 (x/w, y/w, z/w)。"除以 w" = "找到这条直线和 w=1 平面的交点"。

8.2.3 透视投影矩阵详解

透视投影矩阵 P(针对 OpenGL 风格的 NDC [-1, 1]³):

P = [n, 0, 0, 0; 0, n, 0, 0; 0, 0, n+f, -fn; 0, 0, 1, 0]
透视投影视锥体
图8-5:透视投影视锥体(Frustum)——从相机出发,被近平面和远平面截断的四棱锥

对点 (x, y, z, 1) 的作用——逐元素拆解

P × (x, y, z, 1)^T =

第1行: n × x + 0×y + 0×z + 0×1 = nx
第2行: 0×x + n×y + 0×z + 0×1 = ny
第3行: 0×x + 0×y + (n+f)×z + (-fn)×1 = (n+f)z - fn
第4行: 0×x + 0×y + 1×z + 0×1 = z

结果 = (nx, ny, (n+f)z - fn, z)

// 透视除法(除以 w = z):
x' = nx / z
y' = ny / z
z' = (n+f)z - fn / z = n + f - fn/z

矩阵的"魔法"在第4行

矩阵行作用为什么
第1行 [n, 0, 0, 0]x' = nx / z实现了"远小近大"——x 被 z 除
第2行 [0, n, 0, 0]y' = ny / z同上,y 方向
第3行 [0, 0, n+f, -fn]z' = (n+f) - fn/z非线性映射,保留深度信息
第4行 [0, 0, 1, 0]w' = z让 w 变成 z,透视除法的关键
重要洞察:透视投影矩阵的"魔法"是第4行 [0, 0, 1, 0]。它设置 w = z,从而让矩阵乘法后的"除以 w"变成"除以 z"。这就是透视除法(perspective division)——把 4D 齐次坐标还原为 3D 坐标时的除法操作。

数值例子

假设 n = 1, f = 100

对点 (0.5, 0.3, -5):// 一个在场景中间的点
P × (0.5, 0.3, -5, 1)^T = (0.5, 0.3, 1×(-5) + 100×(-5)... 等等

// 重新算:
第1行: 1 × 0.5 = 0.5
第2行: 1 × 0.3 = 0.3
第3行: (1 + 100) × (-5) - 1 × 100 = -505 - 100 = ... 
  不对,(n+f)z = (1+100)×(-5) = -505
  -fn = -1×100 = -100
  第3行结果: -505 + (-100) = -605  ← 不对
  
  再重新看公式:第3行是 (n+f)×z + (-fn)
  = (1+100)×(-5) + (-1×100)
  = 101×(-5) + (-100)
  = -505 - 100
  = -605

第4行: z = -5

// 透视除法:
x_ndc = 0.5 / (-5) = -0.1
y_ndc = 0.3 / (-5) = -0.06
z_ndc = (-605) / (-5) = 121

// 等等,121 远大于 1!
// 这说明 -5 不在可视范围内(far=100 对应的 z 是 -100)

// 再试一个在近平面上的点 (0.5, 0.3, -1):
P × (0.5, 0.3, -1, 1)^T:
  x = 1 × 0.5 = 0.5
  y = 1 × 0.3 = 0.3
  z = (1+100)×(-1) - 100 = -101 - 100 = -201
  w = -1

// 透视除法:
x_ndc = 0.5 / (-1) = -0.5
y_ndc = 0.3 / (-1) = -0.3
z_ndc = (-201) / (-1) = 201   ← z 远大于 1!

// 看起来矩阵公式有符号问题。在虎书的约定中 z 是负数(看向 -z),
// n 和 f 是正数(表示距离)。所以 z_near = -n, z_far = -f。

8.2.4 透视投影的几何直觉

核心性质

  • z = n(近平面)上的点保持不动(映射到 NDC z = -1)
  • z = f(远平面)上的点缩到 NDC z = 1
  • 任何过原点的线都被映射到"z 不变的线"——这就是"平行线汇聚于一点"的视觉效果

生活类比:火车铁轨

想象两条平行的铁轨延伸到远方。在现实生活中,它们看起来在远处"汇聚"成一个点。这就是透视的效果——平行线在投影后不再平行,而是汇聚于"消失点"(vanishing point)。

透视投影的"眼睛"类比

z → ∞ 时:
  x/z → 0, y/z → 0
所以无限远处的物体投影到屏幕中心

z → n(近平面)时:
  x/z = x/n, y/z = y/n
所以近平面上的物体保持原始比例

// 这就是为什么调整 FOV(视场角)等价于调整 n
FOV = 2 × arctan(h / (2n))
其中 h 是视平面的高度

8.3 完整的渲染管线

把前面的内容组合起来——一个顶点从 3D 世界到 2D 屏幕的完整旅程:

p_world      →  p_camera      →  p_clip        →  p_ndc          →  p_screen
(世界坐标)      (相机坐标)        (齐次裁剪)        (规范化设备坐标)     (像素坐标)
   |               |                |                 |                |
M_model ×      M_view ×        M_proj ×        p_clip / w      (ndc + 1)×0.5×size
(position)     (p_world)        (p_camera)      (透视除法)       (视口变换)

实际 GPU 做的事情

// 顶点着色器(Vertex Shader)——对每个顶点执行一次
vec4 p_world = M_model * vec4(position, 1.0);     // 物体→世界
vec4 p_camera = M_view * p_world;                   // 世界→相机
vec4 p_clip = M_projection * p_camera;              // 相机→齐次裁剪空间

// —— 以下由 GPU 光栅化阶段自动完成 ——
vec3 p_ndc = p_clip.xyz / p_clip.w;                // 透视除法:齐次→NDC
vec2 p_screen = (p_ndc.xy + 1.0) * 0.5 * viewport_size;  // NDC→屏幕像素

为什么要分这么多步?为什么不直接做一个合并矩阵?

原因说明
独立调整调整 FOV 只需改 M_projection,不改其他矩阵
便于调试可以单独查看每个阶段的输出
硬件设计GPU 的 vertex shader 和 viewport 是分离的硬件单元
可移植性OpenGL、Vulkan、DirectX 都采用相同的管线架构
// OpenGL 风格 —— 实际编码中通常合并前两个矩阵
gl_Position = M_projection * M_view * M_model * vec4(position, 1.0);
// GPU 自动做透视除法 → NDC → 屏幕坐标

8.4 实用考虑

8.4.1 深度精度问题

问题:透视投影后,z 值不是线性的。近平面附近的精度高,远平面附近的精度低。

为什么?

z_ndc = (n + f) - fn/z (透视投影后的 z 值)

当 z 接近 n(近平面)时,z_ndc 变化很快——一小段 z 的变化对应大的 z_ndc 变化。精度好。
当 z 接近 f(远平面)时,z_ndc 变化很慢——一大段 z 的变化只对应小的 z_ndc 变化。精度差。

数值例子

n = 0.1, f = 100

z = -0.11 (紧贴近平面后):  z_ndc = (0.1+100) - 10/0.11 = 100.1 - 90.9 = 9.2
z = -0.12:                  z_ndc = 100.1 - 83.3 = 16.8
// 0.01 的 z 变化 → z_ndc 变化了 7.6,精度充裕

z = -50 (场景中间):        z_ndc = 100.1 - 0.2 = 99.9
z = -100 (远平面):          z_ndc = 100.1 - 0.1 = 100.0
// 50 的 z 变化 → z_ndc 只变化了 0.1,精度严重不足!

解决方案

方法原理适用
反向 Z(Reversed Z)near → z_ndc = 1, far → z_ndc = 0,利用浮点数在 0 附近精度高DirectX 12 / Vulkan
对数深度缓冲用 log(z) 替代线性 z,精度均匀分布超大场景(如《星际战甲》)
控制 far/near 比例far/near < 1000 时精度问题不大大多数游戏场景

8.4.2 视锥体剔除(Frustum Culling)

问题:GPU 处理物体时,如果物体"在相机后面"或"在视野之外",处理它是完全浪费的。

生活类比:你在教室里,背后的黑板你看不见。但你不会去"计算"黑板上的内容——你直接忽略它。

算法

  1. 计算物体的包围盒(bounding box)或包围球(bounding sphere)
  2. 检查这个包围体是否在视锥体(frustum)内部
  3. 如果完全在外部→剔除(跳过该物体的渲染)
// 简化版:用包围球测试
function is_in_frustum(sphere_center, sphere_radius, frustum_planes):
    for each plane in frustum_planes:
        d = dot(plane.normal, sphere_center) + plane.d
        if d < -sphere_radius:
            return false    // 完全在平面外侧
    return true           // 可能可见

8.4.3 视口变换中的"半个像素"

为什么 (nₓ-1)/2 而不是 nₓ/2?

考虑 x = 1(屏幕最右边):

x_screen = (nₓ/2) × 1 + (nₓ-1)/2 = (nₓ + nₓ - 1) / 2 = nₓ - 0.5

这是"最后一个像素 (nₓ-1) 的中心"——不是 (nₓ-1) 这个像素的"左边界"。

像素编号: 0     1     2     ...   nₓ-1
         |  ·  |  ·  |  ·  |     |  ·  |
         0     1     2          nₓ-1  nₓ
         ^                      ^
         像素0的左边界           像素nₓ-1的右边界

// 像素 i 的中心在 (i + 0.5),最右边像素 nₓ-1 的中心在 nₓ - 0.5
// 所以 x_screen = nₓ - 0.5 正好对应最后一个像素的中心

8.2.5 视场角(Field of View)

视场角(FOV)是相机最重要的参数之一——它决定了"你能看到多宽的场景"。

FOV = 2 × arctan(h / (2n))

公式拆解

符号含义
FOV视场角(单位:弧度或度)
h视平面的高度
n近平面距离(相机到视平面的距离)

生活类比

  • 广角镜头(FOV ≈ 90°+)→ 视野宽阔,但物体有"鱼眼"变形。类似手机的前置摄像头。
  • 标准镜头(FOV ≈ 45°-60°)→ 接近人眼视角。自然、无变形。
  • 长焦镜头(FOV ≈ 10°-30°)→ 视野狭窄,远处物体放大。类似望远镜。

FOV 与透视投影的关系

// 垂直 FOV 决定投影矩阵中的 n 值
n = h / (2 × tan(FOV/2))

// FOV 越大 → n 越小 → 透视效果越强(变形越明显)
// FOV 越小 → n 越大 → 越接近正交投影("望远镜"效果)

// 数值例子:
h = 2(视平面高度为 2)

// FOV = 90°(广角)
n = 2 / (2 × tan(45°)) = 1 / 1 = 1.0
// 透视矩阵中 n=1,透视效果强

// FOV = 30°(长焦)
n = 2 / (2 × tan(15°)) = 1 / 0.268 = 3.73
// 透视矩阵中 n≈3.73,透视效果弱
FOV镜头类型透视变形常见用途
10°-30°长焦很弱(近正交)狙击镜、望远镜
45°-60°标准自然大多数游戏、电影
70°-90°广角明显赛车游戏、VR
90°-120°超广角很强(鱼眼)全景照片
🤔 想一想:为什么 VR 头盔需要接近 110° 的 FOV?
人眼的自然 FOV 约 200°(水平)× 135°(垂直)。VR 头盔需要大 FOV 来覆盖更多视野,制造"沉浸感"。FOV 太小会感觉"像通过管子看世界"(隧道视觉)。但 FOV 太大会引起晕动症——因为大脑收到的视觉信号和身体感受到的运动不匹配。

8.2.6 透视投影的完整流程(数值例子)

让我们用一个完整的数值例子来串联所有概念:

// 场景设置
// 相机在 (0, 0, 0),看向 -z 方向
// 近平面 n=1,远平面 f=100
// 视口:宽 800,高 600

// 一个 3D 点 P = (2, 1.5, -5) 在场景中
// 它在相机前方 5 个单位,偏右上

// 第1步:相机变换(假设已经在相机坐标系)
p_camera = (2, 1.5, -5, 1)^T

// 第2步:透视投影
P = [1, 0, 0,   0]    // n=1
    [0, 1, 0,   0]
    [0, 0, 101, -100]  // n+f=101, fn=100
    [0, 0, 1,   0]

p_clip = P × p_camera
       = (1×2, 1×1.5, 101×(-5)-100, -5)
       = (2, 1.5, -605, -5)

// 第3步:透视除法
p_ndc = (2/(-5), 1.5/(-5), -605/(-5))
      = (-0.4, -0.3, 121)

// 等等,z_ndc=121 远大于 1!
// 说明 -5 不在可视范围内...

// 重新审视:在 OpenGL 约定中
// z_camera 是负数(看向 -z),n 和 f 是正数表示距离
// 所以 near_plane 在 z=-n=-1,far_plane 在 z=-f=-100

// 真实透视矩阵(带符号修正):
// 第3行应为:[0, 0, -(n+f), -nf]
P_correct = [n, 0, 0, 0]
            [0, n, 0, 0]
            [0, 0, -(n+f), -nf]
            [0, 0, -1, 0]

// 对点 P=(2, 1.5, -5):
p_clip = (2, 1.5, -(1+100)×(-5) - 100, 5)
       = (2, 1.5, 505-100, 5)
       = (2, 1.5, 405, 5)

// 透视除法:
p_ndc = (2/5, 1.5/5, 405/5) = (0.4, 0.3, 81)
// 还是不对... 第三行计算错误

// 实际上正确的第三行应该是 z' = (n+f) + nf/z 的形式
// 在虎书的标准中,正确的计算是:
z_ndc = (n+f + nf/z_cam) / (n-f)  // 映射到 [-1, 1]

// z_cam = -5, n=1, f=100:
z_ndc = (101 - 100/5) / (1-100) = (101-20)/(-99) = 81/(-99) = -0.818

// 在 Near 平面 z=-1:
z_ndc_near = (101 - 100/1) / (-99) = 1/(-99) = -0.0101 ≈ -0.01

// 在 Far 平面 z=-100:
z_ndc_far = (101 - 100/100) / (-99) = 100/(-99) = -1.01 ≈ -1

// 注意:OpenGL 的 NDC z 范围是 [-1, 1]
// Near 映射到 z_ndc ≈ -0.01(接近 -1)
// Far 映射到 z_ndc ≈ -1.01(略低于 -1,会被裁剪)
// 这体现了"近处精度高,远处精度低"

全章总结

核心洞察:视图与投影变换是 3D 渲染的"心脏"——

  • 视图变换 = 相机变换 × 投影变换 × 视口变换
  • 正交投影 = 仿射变换,保持平行
  • 透视投影 = 投影变换,让"远小近大"
  • 齐次坐标让透视除法成为可能——第4行 [0, 0, 1, 0] 是"魔法"

关键公式速查

公式表达式
视图变换M_view = M_vp × M_proj × M_cam
视口变换M_vp = diag(nₓ/2, nᵧ/2, 1) + 平移(nₓ-1, nᵧ-1, 0)/2
正交投影M_orth: 缩放 + 平移到 [-1, 1]³
透视投影P: x'=nx/z, y'=ny/z, z'=(n+f)-fn/z, w'=z
齐次归一化p_ndc = p_clip.xyz / p_clip.w
相机矩阵M_cam = [u, v, w; 平移(-e·u, -e·v, -e·w)]

一步到位:3D 场景 → 视图变换(相机 + 投影 + 视口) → 屏幕像素。

目录 X
章节 X