第4章:齐次坐标与投影
4.1 齐次坐标
4.1.1 为什么需要齐次坐标
在第3章中,我们遇到了一个问题:平移变换无法用普通的矩阵乘法表示。齐次坐标(Homogeneous Coordinates)的引入,优雅地解决了这个问题。
问题回顾
2D 点 (x, y) 平移 (tx, ty) 后变成 (x+tx, y+ty)
普通矩阵无法表示平移:
┌ ┐ ┌ ┐ ┌ ┐
│ a b │ × │ x │ = │ ax + by│ ← 无法得到 x + tx
│ c d │ │ y │ │ cx + dy│
└ ┘ └ ┘ └ ┘
解决方案:增加一个维度
2D 点 (x, y) → 齐次坐标 (x, y, 1)
┌ ┐ ┌ ┐ ┌ ┐
│ 1 0 tx │ │ x │ │ x+tx │
│ 0 1 ty │ × │ y │ = │ y+ty │
│ 0 0 1 │ │ 1 │ │ 1 │
└ ┘ └ ┘ └ ┘
现在平移可以用矩阵乘法表示了!4.1.2 齐次坐标的定义
齐次坐标定义
n 维欧几里得空间中的点,可以用 n+1 维齐次坐标表示:
2D 点 (x, y) → 齐次坐标 (x, y, w),其中 w ≠ 0
3D 点 (x, y, z) → 齐次坐标 (x, y, z, w),其中 w ≠ 0
从齐次坐标转回笛卡尔坐标:
(x, y, w) → (x/w, y/w)
(x, y, z, w) → (x/w, y/w, z/w)
重要性质:
齐次坐标 (kx, ky, kw) 表示的是同一个点(k ≠ 0)
例如:(2, 4, 1)、(4, 8, 2)、(6, 12, 3) 都表示点 (2, 4)
标准化:
通常使用 w = 1 的形式:(x, y, 1) 或 (x, y, z, 1)4.1.3 齐次坐标的几何意义
几何直观
2D 齐次坐标实际上是在 3D 空间中的一条射线上的点!
z (w)
▲
│
│ • (2, 4, 2) ← 射线上的点
│ ╱
│ ╱
│ • (1, 2, 1) ← w=1 平面上的点
│ ╱
│ ╱
│ • (0.5, 1, 0.5)
│ ╱
│╱
──────────────O──────────────► x
╱│
╱ │
╱ │
╱ │
▼
y
所有满足 (kx, ky, k) 的点都在同一条射线上,
它们都表示同一个 2D 点 (x, y)。
w = 1 平面:
就是我们平时看到的 2D 平面,所有实际的点都在这个平面上。4.1.4 点与向量的区别
齐次坐标区分点和向量
点(Position):w = 1
(x, y, 1) 表示位置 (x, y)
向量(Direction):w = 0
(x, y, 0) 表示方向 (x, y)
为什么这样定义?
平移矩阵作用于点:
┌ ┐ ┌ ┐ ┌ ┐
│ 1 0 tx │ │ x │ │ x+tx │
│ 0 1 ty │ × │ y │ = │ y+ty │ ← 点被平移了
│ 0 0 1 │ │ 1 │ │ 1 │
└ ┘ └ ┘ └ ┘
平移矩阵作用于向量:
┌ ┐ ┌ ┐ ┌ ┐
│ 1 0 tx │ │ x │ │ x │
│ 0 1 ty │ × │ y │ = │ y │ ← 向量不受平移影响!
│ 0 0 1 │ │ 0 │ │ 0 │
└ ┘ └ ┘ └ ┘
这符合物理直觉:
- 点是位置,平移后位置改变
- 向量是方向,平移不改变方向4.1.5 代码实现
javascript
class HomogeneousPoint2D {
constructor(x, y, w = 1) {
this.x = x;
this.y = y;
this.w = w;
}
// 转换为笛卡尔坐标
toCartesian() {
if (this.w === 0) {
throw new Error('Cannot convert direction to point');
}
return {
x: this.x / this.w,
y: this.y / this.w
};
}
// 规范化(使 w = 1)
normalize() {
if (this.w === 0) {
return this; // 向量保持不变
}
return new HomogeneousPoint2D(
this.x / this.w,
this.y / this.w,
1
);
}
// 创建点
static point(x, y) {
return new HomogeneousPoint2D(x, y, 1);
}
// 创建向量
static vector(x, y) {
return new HomogeneousPoint2D(x, y, 0);
}
// 是否是点
isPoint() {
return this.w !== 0;
}
// 是否是向量
isVector() {
return this.w === 0;
}
}4.2 仿射变换
4.2.1 仿射变换的定义
仿射变换(Affine Transformation)是保持直线性质的变换。它包括:线性变换(缩放、旋转、切变)+ 平移。
仿射变换的性质
仿射变换保持:
1. 共线性:变换前在一条直线上的点,变换后仍在一条直线上
2. 平行性:变换前平行的线,变换后仍然平行
3. 比例关系:线段的分割比例保持不变
仿射变换不保持:
1. 角度:角度可能改变
2. 长度:长度可能改变(缩放会改变)
3. 面积:面积可能改变(非等比缩放)
仿射变换示例:
原图形(正方形) 缩放后 旋转后 切变后
┌───┐ ┌──────────┐ ◇ ╱╲
│ │ │ │ ╱╲ ╱ ╲
│ │ ──► │ │ ──► ╱ ╲ ──► ╱ ╲
└───┘ └──────────┘ ◇ ◇ ╱──────╲
所有这些变换都是仿射变换4.2.2 仿射变换矩阵
2D 仿射变换的通用形式
┌ ┐
│ a b tx │
│ c d ty │ ← 最后一行始终是 [0, 0, 1]
│ 0 0 1 │
└ ┘
其中:
- [a, b; c, d] 是线性变换部分(2×2)
- [tx, ty] 是平移部分
作用于点 (x, y, 1):
x' = ax + by + tx
y' = cx + dy + ty
分解:
┌ ┐ ┌ ┐ ┌ ┐
│ a b tx │ │ 1 0 tx │ │ a b 0│
│ c d ty │ = │ 0 1 ty │ × │ c d 0│
│ 0 0 1 │ │ 0 0 1 │ │ 0 0 1│
└ ┘ └ ┘ └ ┘
平移T 线性L
仿射变换 = 平移 × 线性变换4.2.3 仿射变换的应用
javascript
/**
* 2D 仿射变换类
*/
class AffineTransform {
constructor() {
// [a, c, 0, b, d, 0, tx, ty, 1]
this.matrix = new Float32Array([
1, 0, 0,
0, 1, 0,
0, 0, 1
]);
}
// 应用变换到点
transformPoint(x, y) {
const m = this.matrix;
return {
x: m[0] * x + m[3] * y + m[6],
y: m[1] * x + m[4] * y + m[7]
};
}
// 应用变换到向量(忽略平移)
transformVector(x, y) {
const m = this.matrix;
return {
x: m[0] * x + m[3] * y,
y: m[1] * x + m[4] * y
};
}
// 从 Canvas 的 transform 参数创建
static fromCanvas(a, b, c, d, e, f) {
const t = new AffineTransform();
t.matrix.set([
a, b, 0,
c, d, 0,
e, f, 1
]);
return t;
}
// 导出为 Canvas 格式
toCanvasArgs() {
const m = this.matrix;
return [m[0], m[1], m[3], m[4], m[6], m[7]];
}
}
// Canvas 使用示例
const ctx = canvas.getContext('2d');
const transform = new AffineTransform();
// ... 设置变换 ...
ctx.setTransform(...transform.toCanvasArgs());4.3 正交投影
4.3.1 投影的概念
投影(Projection)是将高维空间中的物体映射到低维空间的过程。在图形学中,最常见的是将 3D 场景投影到 2D 屏幕。
投影的基本概念
3D 世界
│
│ 投影
▼
2D 屏幕
两种主要的投影类型:
1. 正交投影(Orthographic Projection)
- 平行光投影
- 物体大小不随距离变化
- 用于工程图、CAD
2. 透视投影(Perspective Projection)
- 模拟人眼/相机
- 近大远小
- 用于游戏、影视4.3.2 正交投影的原理
正交投影
正交投影简单地丢弃 z 坐标:
3D 点 (x, y, z) → 2D 点 (x, y)
正交投影矩阵(简单版):
┌ ┐
│ 1 0 0 0 │
│ 0 1 0 0 │
│ 0 0 0 0 │ ← z 被丢弃
│ 0 0 0 1 │
└ ┘
视觉效果:
正交投影
│
┌─────┼─────┐
│ │ │
│ ┌─┼─┐ │
│ │ │ │ │
│ └─┼─┘ │
│ │ │
└─────┼─────┘
│
结果:
┌───────────┐
│ │
│ ┌───┐ │
│ │ │ │
│ └───┘ │
│ │
└───────────┘
近处和远处的物体大小相同4.3.3 标准正交投影矩阵
实际应用中,需要将一个 3D 包围盒映射到标准化设备坐标(NDC):
正交投影矩阵
将 [left, right] × [bottom, top] × [near, far] 映射到 [-1, 1]³
┌ ┐
│ 2/(r-l) 0 0 tx │
│ 0 2/(t-b) 0 ty │
│ 0 0 -2/(f-n) tz │
│ 0 0 0 1 │
└ ┘
其中:
tx = -(r+l)/(r-l)
ty = -(t+b)/(t-b)
tz = -(f+n)/(f-n)
简化版本(对称情况):
如果 left = -right, bottom = -top:
┌ ┐
│ 1/right 0 0 0 │
│ 0 1/top 0 0 │
│ 0 0 -2/(f-n) tz│
│ 0 0 0 1 │
└ ┘4.3.4 代码实现
javascript
class Matrix4 {
// ... 前面的代码 ...
/**
* 创建正交投影矩阵
* @param left 左边界
* @param right 右边界
* @param bottom 下边界
* @param top 上边界
* @param near 近平面
* @param far 远平面
*/
static orthographic(left, right, bottom, top, near, far) {
const m = new Matrix4();
const e = m.elements;
const rl = right - left;
const tb = top - bottom;
const fn = far - near;
e[0] = 2 / rl;
e[5] = 2 / tb;
e[10] = -2 / fn;
e[12] = -(right + left) / rl;
e[13] = -(top + bottom) / tb;
e[14] = -(far + near) / fn;
return m;
}
/**
* 简化的正交投影(对称)
*/
static orthoSymmetric(width, height, near, far) {
return Matrix4.orthographic(
-width / 2, width / 2,
-height / 2, height / 2,
near, far
);
}
}
// 使用示例
const ortho = Matrix4.orthographic(-10, 10, -10, 10, 0.1, 100);4.4 透视投影
4.4.1 透视投影的原理
透视投影的直觉
透视投影模拟人眼或相机的成像原理:近大远小。
眼睛/相机
•
╱│╲
╱ │ ╲
╱ │ ╲
╱ │ ╲
╱ │ ╲
╱ │ ╲
╱ │ ╲
┌───────┼───────┐ ← 近平面(屏幕)
│ 近 │ 物 │
│ 处 │ 体 │
└───────┼───────┘
│
┌───────────────────┐
│ 远处物体 │ ← 投影后变小
└───────────────────┘
数学关系:
设相机在原点,看向 -z 方向,
点 P(x, y, z) 投影到近平面 z = -n:
相机
O
╱│
╱ │
╱ │ n(近平面距离)
╱ │
P────┼──────── z = -n 平面
│ │
│ │ z(物体深度)
│ │
●────┴─ 实际物体位置
相似三角形:
x'/n = x/|z| → x' = nx/|z| = -nx/z
y'/n = y/|z| → y' = ny/|z| = -ny/z4.4.2 透视除法
透视投影的关键:透视除法
透视投影需要除以 z,但矩阵乘法不能直接做除法。
解决方案:利用齐次坐标!
┌ ┐ ┌ ┐ ┌ ┐
│ n 0 0 0 │ │ x │ │ nx │
│ 0 n 0 0 │ × │ y │ = │ ny │
│ 0 0 A B │ │ z │ │ Az+B│
│ 0 0 -1 0 │ │ 1 │ │ -z │
└ ┘ └ ┘ └ ┘
得到齐次坐标 (nx, ny, Az+B, -z)
转换为笛卡尔坐标(除以 w = -z):
x' = nx/(-z) = -nx/z
y' = ny/(-z) = -ny/z
z' = (Az+B)/(-z) = -A - B/z
透视除法发生在:
齐次坐标 → 笛卡尔坐标 的转换过程中
这是由 GPU 硬件自动完成的!4.4.3 透视投影矩阵
标准透视投影矩阵
将视锥体映射到 NDC [-1, 1]³
┌ ┐
│ 2n/(r-l) 0 (r+l)/(r-l) 0│
│ 0 2n/(t-b) (t+b)/(t-b) 0│
│ 0 0 -(f+n)/(f-n) -2fn/(f-n)│
│ 0 0 -1 0│
└ ┘
使用视场角(FOV)的版本:
┌ ┐
│ 1/(a·tan(fov/2)) 0 0 0│
│ 0 1/tan(fov/2) 0 0│
│ 0 0 A B│
│ 0 0 -1 0│
└ ┘
其中:
a = aspect ratio(宽高比)= width/height
fov = 垂直视场角
A = -(f+n)/(f-n)
B = -2fn/(f-n)4.4.4 代码实现
javascript
class Matrix4 {
// ... 前面的代码 ...
/**
* 创建透视投影矩阵
* @param fov 垂直视场角(弧度)
* @param aspect 宽高比
* @param near 近平面
* @param far 远平面
*/
static perspective(fov, aspect, near, far) {
const m = new Matrix4();
const e = m.elements;
const f = 1 / Math.tan(fov / 2);
const nf = 1 / (near - far);
e[0] = f / aspect;
e[5] = f;
e[10] = (far + near) * nf;
e[11] = -1;
e[14] = 2 * far * near * nf;
e[15] = 0;
return m;
}
/**
* 透视投影(指定边界)
*/
static frustum(left, right, bottom, top, near, far) {
const m = new Matrix4();
const e = m.elements;
const rl = right - left;
const tb = top - bottom;
const fn = far - near;
e[0] = 2 * near / rl;
e[5] = 2 * near / tb;
e[8] = (right + left) / rl;
e[9] = (top + bottom) / tb;
e[10] = -(far + near) / fn;
e[11] = -1;
e[14] = -2 * far * near / fn;
e[15] = 0;
return m;
}
}
// 使用示例
const fov = 60 * Math.PI / 180; // 60 度
const aspect = canvas.width / canvas.height;
const projection = Matrix4.perspective(fov, aspect, 0.1, 1000);4.5 视锥体
4.5.1 视锥体的概念
视锥体(View Frustum)
视锥体是相机能够"看到"的空间区域。
透视投影的视锥体: 正交投影的视锥体:
相机 相机
• │
╱│╲ │
╱ │ ╲ ┌────┼────┐
╱ │ ╲ │ │ │
╱ │ ╲ ← 远平面 │ │ │ ← 远平面
╱ │ ╲ │ │ │
╱─────┼─────╲ ← 近平面 ├────┼────┤ ← 近平面
╱ │ ╲ │ │ │
╱ │ ╲ └────┴────┘
视锥体是一个截头锥体 视锥体是一个长方体
视锥体的 6 个平面:
1. 近平面(Near)
2. 远平面(Far)
3. 左平面(Left)
4. 右平面(Right)
5. 上平面(Top)
6. 下平面(Bottom)4.5.2 视锥体剔除
视锥体剔除(Frustum Culling)
只渲染视锥体内的物体,提高性能。
视锥体
╱───────────╲
╱ ╲
╱ ○ ←渲染 ╲
╱ ┌─┐ ╲
╱ │ │ ╲
╱─────────┴─┴─────────╲
╱ ╲
╱ × × ╲ ← 这两个物体在视锥体外,不渲染
╱ ┌─┐ ┌─┐ ╲
╱ └─┘ └─┘ ╲
判断步骤:
1. 获取物体的包围盒(AABB 或 OBB)
2. 检查包围盒与视锥体 6 个平面的关系
3. 如果完全在任一平面外侧,则剔除4.5.3 代码实现
javascript
/**
* 视锥体类
*/
class Frustum {
constructor() {
// 6 个平面,每个平面用 (a, b, c, d) 表示:ax + by + cz + d = 0
this.planes = [
{ a: 0, b: 0, c: 0, d: 0 }, // near
{ a: 0, b: 0, c: 0, d: 0 }, // far
{ a: 0, b: 0, c: 0, d: 0 }, // left
{ a: 0, b: 0, c: 0, d: 0 }, // right
{ a: 0, b: 0, c: 0, d: 0 }, // top
{ a: 0, b: 0, c: 0, d: 0 } // bottom
];
}
/**
* 从投影矩阵和视图矩阵提取视锥体平面
*/
setFromMatrix(viewProjection) {
const m = viewProjection.elements;
// 左平面
this.planes[2].a = m[3] + m[0];
this.planes[2].b = m[7] + m[4];
this.planes[2].c = m[11] + m[8];
this.planes[2].d = m[15] + m[12];
// 右平面
this.planes[3].a = m[3] - m[0];
this.planes[3].b = m[7] - m[4];
this.planes[3].c = m[11] - m[8];
this.planes[3].d = m[15] - m[12];
// 底平面
this.planes[5].a = m[3] + m[1];
this.planes[5].b = m[7] + m[5];
this.planes[5].c = m[11] + m[9];
this.planes[5].d = m[15] + m[13];
// 顶平面
this.planes[4].a = m[3] - m[1];
this.planes[4].b = m[7] - m[5];
this.planes[4].c = m[11] - m[9];
this.planes[4].d = m[15] - m[13];
// 近平面
this.planes[0].a = m[3] + m[2];
this.planes[0].b = m[7] + m[6];
this.planes[0].c = m[11] + m[10];
this.planes[0].d = m[15] + m[14];
// 远平面
this.planes[1].a = m[3] - m[2];
this.planes[1].b = m[7] - m[6];
this.planes[1].c = m[11] - m[10];
this.planes[1].d = m[15] - m[14];
// 归一化所有平面
for (const plane of this.planes) {
const len = Math.sqrt(
plane.a * plane.a +
plane.b * plane.b +
plane.c * plane.c
);
plane.a /= len;
plane.b /= len;
plane.c /= len;
plane.d /= len;
}
}
/**
* 检查点是否在视锥体内
*/
containsPoint(x, y, z) {
for (const plane of this.planes) {
const dist = plane.a * x + plane.b * y + plane.c * z + plane.d;
if (dist < 0) return false;
}
return true;
}
/**
* 检查球体是否与视锥体相交
*/
intersectsSphere(x, y, z, radius) {
for (const plane of this.planes) {
const dist = plane.a * x + plane.b * y + plane.c * z + plane.d;
if (dist < -radius) return false;
}
return true;
}
/**
* 检查 AABB 是否与视锥体相交
*/
intersectsAABB(minX, minY, minZ, maxX, maxY, maxZ) {
for (const plane of this.planes) {
// 找到最靠近平面的顶点
const px = plane.a > 0 ? maxX : minX;
const py = plane.b > 0 ? maxY : minY;
const pz = plane.c > 0 ? maxZ : minZ;
const dist = plane.a * px + plane.b * py + plane.c * pz + plane.d;
if (dist < 0) return false;
}
return true;
}
}4.6 视口变换
4.6.1 坐标空间总览
3D 渲染的坐标变换流程
局部空间 ──► 世界空间 ──► 相机空间 ──► 裁剪空间 ──► NDC ──► 屏幕空间
Model View Projection 透视除法 Viewport
1. 局部空间(Local/Object Space)
- 物体自身的坐标系
- 以物体中心为原点
2. 世界空间(World Space)
- 整个场景的坐标系
- Model 矩阵:放置物体到世界中
3. 相机空间(View/Camera/Eye Space)
- 以相机为原点
- View 矩阵:相机的逆变换
4. 裁剪空间(Clip Space)
- 齐次坐标空间
- Projection 矩阵:应用投影
5. 标准化设备坐标(NDC)
- [-1, 1]³ 的立方体
- 透视除法:w 分量除法
6. 屏幕空间(Screen Space)
- 像素坐标
- Viewport 变换:映射到屏幕4.6.2 视口变换矩阵
视口变换
将 NDC [-1, 1] × [-1, 1] 映射到屏幕 [x, x+w] × [y, y+h]
变换公式:
x_screen = (x_ndc + 1) × (width / 2) + x
y_screen = (1 - y_ndc) × (height / 2) + y ← 注意:y 轴翻转
或者(如果 y 不翻转):
y_screen = (y_ndc + 1) × (height / 2) + y
深度映射:
z_screen = (z_ndc + 1) × (depthRange / 2) + depthNear
通常 depthRange = 1, depthNear = 0
z_screen = (z_ndc + 1) / 2,范围 [0, 1]
视口矩阵:
┌ ┐
│ w/2 0 0 x + w/2 │
│ 0 h/2 0 y + h/2 │ ← 如果 y 不翻转
│ 0 0 d/2 d/2 │
│ 0 0 0 1 │
└ ┘
其中 d = depthRange4.6.3 完整的 MVP 变换
javascript
/**
* 相机类
*/
class Camera {
constructor() {
this.position = { x: 0, y: 0, z: 5 };
this.target = { x: 0, y: 0, z: 0 };
this.up = { x: 0, y: 1, z: 0 };
this.fov = 60 * Math.PI / 180;
this.aspect = 1;
this.near = 0.1;
this.far = 1000;
this.viewMatrix = new Matrix4();
this.projectionMatrix = new Matrix4();
this.viewProjectionMatrix = new Matrix4();
}
/**
* 创建 lookAt 视图矩阵
*/
updateViewMatrix() {
const m = this.viewMatrix;
const e = m.elements;
// 计算相机坐标系
const zAxis = normalize({
x: this.position.x - this.target.x,
y: this.position.y - this.target.y,
z: this.position.z - this.target.z
});
const xAxis = normalize(cross(this.up, zAxis));
const yAxis = cross(zAxis, xAxis);
// 构建视图矩阵
e[0] = xAxis.x; e[4] = xAxis.y; e[8] = xAxis.z;
e[1] = yAxis.x; e[5] = yAxis.y; e[9] = yAxis.z;
e[2] = zAxis.x; e[6] = zAxis.y; e[10] = zAxis.z;
e[12] = -dot(xAxis, this.position);
e[13] = -dot(yAxis, this.position);
e[14] = -dot(zAxis, this.position);
return this;
}
/**
* 更新投影矩阵
*/
updateProjectionMatrix() {
this.projectionMatrix = Matrix4.perspective(
this.fov, this.aspect, this.near, this.far
);
return this;
}
/**
* 更新视图投影矩阵
*/
updateViewProjectionMatrix() {
this.viewProjectionMatrix = this.projectionMatrix.multiply(
this.viewMatrix
);
return this;
}
/**
* 世界坐标转屏幕坐标
*/
worldToScreen(point, viewportWidth, viewportHeight) {
// 应用 VP 矩阵
const clip = this.viewProjectionMatrix.transformPoint(
point.x, point.y, point.z
);
// 透视除法已在 transformPoint 中完成
// 转换到屏幕坐标
return {
x: (clip.x + 1) * viewportWidth / 2,
y: (1 - clip.y) * viewportHeight / 2, // y 翻转
z: (clip.z + 1) / 2 // 深度 [0, 1]
};
}
/**
* 屏幕坐标转射线(用于拾取)
*/
screenToRay(screenX, screenY, viewportWidth, viewportHeight) {
// 屏幕坐标转 NDC
const ndcX = (screenX / viewportWidth) * 2 - 1;
const ndcY = 1 - (screenY / viewportHeight) * 2;
// 获取 VP 矩阵的逆
const invVP = this.viewProjectionMatrix.inverse();
// 近平面和远平面上的点
const nearPoint = invVP.transformPoint(ndcX, ndcY, -1);
const farPoint = invVP.transformPoint(ndcX, ndcY, 1);
// 射线方向
const direction = normalize({
x: farPoint.x - nearPoint.x,
y: farPoint.y - nearPoint.y,
z: farPoint.z - nearPoint.z
});
return {
origin: this.position,
direction: direction
};
}
}
// 辅助函数
function dot(a, b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
function cross(a, b) {
return {
x: a.y * b.z - a.z * b.y,
y: a.z * b.x - a.x * b.z,
z: a.x * b.y - a.y * b.x
};
}
function normalize(v) {
const len = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
return { x: v.x / len, y: v.y / len, z: v.z / len };
}4.7 本章小结
核心概念
| 概念 | 说明 | 用途 |
|---|---|---|
| 齐次坐标 | (x, y, w) 表示 2D 点 | 统一处理平移和线性变换 |
| 仿射变换 | 保持直线和平行的变换 | 2D/3D 图形变换 |
| 正交投影 | 平行投影,无透视 | CAD、2D 游戏 |
| 透视投影 | 近大远小 | 3D 游戏、影视 |
| 视锥体 | 相机可见区域 | 剔除优化 |
| 视口变换 | NDC 到屏幕坐标 | 最终显示 |
坐标空间变换链
物体坐标 ─[Model]─► 世界坐标 ─[View]─► 相机坐标 ─[Projection]─► 裁剪坐标
│
[透视除法]
│
▼
屏幕坐标 ◄─[Viewport]─ NDC坐标关键矩阵
正交投影: 透视投影:
┌ ┐ ┌ ┐
│ 2/(r-l) 0 0 tx│ │ f/a 0 0 0 │
│ 0 2/(t-b) 0 ty│ │ 0 f 0 0 │
│ 0 0 -2/(f-n) tz│ │ 0 0 A B │
│ 0 0 0 1 │ │ 0 0 -1 0 │
└ ┘ └ ┘
其中 f = 1/tan(fov/2), A = -(f+n)/(f-n), B = -2fn/(f-n)下一章预告:在第5章中,我们将学习经典的 2D 图形绘制算法,包括直线、圆、多边形的绘制。
文档版本:v1.0
字数统计:约 10,000 字
