第 6 章:坐标变换
第 6 章:坐标变换
坐标变换是 3D 图形编程的数学基础。通过模型、视图、投影三个矩阵,我们将 3D 世界中的物体投影到 2D 屏幕上。
6.1 坐标空间变换链
从模型的本地坐标到屏幕像素,数据经过 5 个坐标空间:
模型空间 (Local/Object Space)
│ Model Matrix (模型矩阵)
▼
世界空间 (World Space)
│ View Matrix (视图矩阵)
▼
观察空间 (View/Camera Space)
│ Projection Matrix (投影矩阵)
▼
裁剪空间 (Clip Space)
│ 透视除法 (gl_Position.xyz / gl_Position.w)
▼
NDC (Normalized Device Coordinates): [-1, 1]³
│ 视口变换
▼
屏幕空间 (Screen Space): [0, width] × [0, height]
| 空间 | 坐标范围 | 说明 |
|---|---|---|
| 模型空间 | 物体定义 | 以物体中心为原点 |
| 世界空间 | 全局场景 | 物体在世界中的位置 |
| 观察空间 | 以相机为原点 | Z 轴指向相机后方 |
| 裁剪空间 | [-w, w] | 超出范围的被裁剪 |
| NDC | [-1, 1] | 透视除法后的结果 |
| 屏幕空间 | 像素坐标 | 最终显示位置 |
6.2 向量基础
6.2.1 向量运算
向量加法: 向量缩放: 点积: 叉积:
v1 + v2 k * v v1 · v2 v1 × v2
╱╲ ╱╲ ╱╲ ╱╲ |v1||v2|cos θ 垂直于两向量的平面
╱ ╲+╱ ╲ → ╱ ╲ →╱ ╲ 返回标量 返回向量 (仅 vec3)
| 运算 | 公式 | 用途 |
|---|---|---|
| 加法 | (a.x+b.x, a.y+b.y, ...) | 位移 |
| 缩放 | (k*a.x, k*a.y, ...) | 放大/缩小 |
| 点积 | a.x*b.x + a.y*b.y + ... | 角度、投影 |
| 叉积 | (a.y*b.z-a.z*b.y, ...) | 法线、朝向 |
| 归一化 | `v / | v |
6.3 矩阵基础
6.3.1 齐次坐标
3D 变换中的平移无法用 3×3 矩阵表示,因此引入齐次坐标:
3D 点: (x, y, z) → 齐次坐标: (x, y, z, 1)
3D 向量: (x, y, z) → 齐次坐标: (x, y, z, 0)
💡 向量的 w=0,因此平移不会影响向量(这是正确的——向量只有方向,没有位置)。
6.3.2 4×4 变换矩阵结构
┌ R R R Tx ┐ R = 旋转 (3×3)
│ R R R Ty │ T = 平移 (3×1)
│ R R R Tz │ [0 0 0 1] = 齐次行
│ 0 0 0 1 ┘
列主序存储 (OpenGL):
[0] [4] [8] [12] ← m[0] 是第一列
[1] [5] [9] [13]
[2] [6] [10] [14]
[3] [7] [11] [15]
6.4 基本变换
6.4.1 平移(Translation)
平移矩阵 T(tx, ty, tz):
┌ 1 0 0 tx ┐ ┌ x + tx ┐
│ 0 1 0 ty │ × │ y │ = │ y + ty │
│ 0 0 1 tz │ │ z │ │ z + tz │
│ 0 0 0 1 ┘ │ 1 ┘ │ 1 ┘
6.4.2 缩放(Scale)
缩放矩阵 S(sx, sy, sz):
┌ sx 0 0 0 ┐ ┌ x*sx ┐
│ 0 sy 0 0 │ × │ y │ = │ y*sy │
│ 0 0 sz 0 │ │ z │ │ z*sz │
│ 0 0 0 1 ┘ │ 1 ┘ │ 1 ┘
6.4.3 旋转(Rotation)
绕 Z 轴旋转 θ:
┌ cos θ -sin θ 0 0 ┐
│ sin θ cos θ 0 0 │
│ 0 0 1 0 │
│ 0 0 0 1 ┘
绕 X 轴旋转 θ:
┌ 1 0 0 0 ┐
│ 0 cos θ -sin θ 0 │
│ 0 sin θ cos θ 0 │
│ 0 0 0 1 ┘
绕 Y 轴旋转 θ:
┌ cos θ 0 sin θ 0 ┐
│ 0 1 0 0 │
│ -sin θ 0 cos θ 0 │
│ 0 0 0 1 ┘
⚠️ 矩阵乘法顺序:先缩放 → 再旋转 → 最后平移。组合矩阵
M = T × R × S。GLM 中按代码编写的逆序相乘:model = translate * rotate * scale。
6.5 GLM 数学库
6.5.1 基本用法
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
// 向量
glm::vec3 position(1.0f, 2.0f, 3.0f);
glm::vec3 direction = glm::normalize(glm::vec3(1.0f, 0.0f, -1.0f));
// 矩阵变换
glm::mat4 model = glm::mat4(1.0f); // 单位矩阵
model = glm::translate(model, glm::vec3(0.0f, -1.0f, 0.0f));
model = glm::rotate(model, glm::radians(45.0f), glm::vec3(0.0f, 1.0f, 0.0f));
model = glm::scale(model, glm::vec3(0.5f, 0.5f, 0.5f));
// 传递给着色器
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
6.5.2 GLM 常用函数
| 函数 | 说明 |
|---|---|
glm::translate(mat4, vec3) | 平移 |
glm::rotate(mat4, angle, axis) | 旋转(弧度) |
glm::scale(mat4, vec3) | 缩放 |
glm::radians(degrees) | 角度转弧度 |
glm::normalize(vec) | 归一化 |
glm::cross(a, b) | 叉积 |
glm::dot(a, b) | 点积 |
glm::length(vec) | 向量长度 |
glm::inverse(mat) | 矩阵求逆 |
glm::value_ptr(mat) | 获取数据指针 |
glm::perspective(fov, aspect, near, far) | 透视投影 |
glm::ortho(l, r, b, t, near, far) | 正交投影 |
glm::lookAt(eye, center, up) | 视图矩阵 |
6.6 模型矩阵(Model Matrix)
模型矩阵将物体从模型空间变换到世界空间:
// 创建模型矩阵:先缩放 → 再旋转 → 最后平移
glm::mat4 model = glm::mat4(1.0f);
model = glm::translate(model, worldPosition); // 世界位置
model = glm::rotate(model, glm::radians(rotation), axis); // 旋转
model = glm::scale(model, glm::vec3(scaleFactor)); // 缩放
同一个立方体,不同模型矩阵:
┌─────────┐ 模型矩阵 A ┌─────────┐
│ 立方体 │ ──────────▶ │ 位于(2,0,0)
│ (原点) │ │ 旋转45° │
└─────────┘ │ 缩放0.5x │
└─────────┘
6.7 视图矩阵(View Matrix)
视图矩阵将世界空间变换到以相机为中心的观察空间:
6.7.1 相机三要素
| 要素 | 说明 | 示例 |
|---|---|---|
| 位置 (Position) | 相机在世界空间的位置 | vec3(0, 2, 5) |
| 目标 (Target) | 相机看向的点 | vec3(0, 0, 0) |
| 上方向 (Up) | 相机的"头顶"方向 | vec3(0, 1, 0) |
6.7.2 lookAt 矩阵
glm::vec3 cameraPos = glm::vec3(0.0f, 2.0f, 5.0f);
glm::vec3 cameraTarget = glm::vec3(0.0f, 0.0f, 0.0f);
glm::vec3 cameraUp = glm::vec3(0.0f, 1.0f, 0.0f);
glm::mat4 view = glm::lookAt(cameraPos, cameraTarget, cameraUp);
Up (0,1,0)
│
│ ┌─── 相机位置 (0,2,5)
▼ ▼
[C]──────→ 目标方向
╱
╱ Right
↙
6.7.3 FPS 相机实现
// 第一人称相机
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f);
glm::vec3 cameraFront = glm::vec3(0.0f, 0.0f, -1.0f);
glm::vec3 cameraUp = glm::vec3(0.0f, 1.0f, 0.0f);
float yaw = -90.0f; // 偏航角
float pitch = 0.0f; // 俯仰角
float speed = 2.5f;
// 鼠标回调:更新朝向
void mouse_callback(GLFWwindow* window, double xpos, double ypos) {
float xoffset = xpos - lastX;
float yoffset = lastY - ypos;
lastX = xpos;
lastY = ypos;
float sensitivity = 0.1f;
xoffset *= sensitivity;
yoffset *= sensitivity;
yaw += xoffset;
pitch += yoffset;
pitch = glm::clamp(pitch, -89.0f, 89.0f);
glm::vec3 front;
front.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch));
front.y = sin(glm::radians(pitch));
front.z = sin(glm::radians(yaw)) * cos(glm::radians(pitch));
cameraFront = glm::normalize(front);
}
// 每帧更新视图矩阵
view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);
6.8 投影矩阵(Projection Matrix)
6.8.1 透视投影
透视投影模拟人眼视角——近大远小:
float fov = glm::radians(45.0f); // 视场角
float aspect = 800.0f / 600.0f; // 宽高比
float near = 0.1f; // 近裁剪面
float far = 100.0f; // 远裁剪面
glm::mat4 projection = glm::perspective(fov, aspect, near, far);
透视投影视锥体 (Frustum):
far plane
┌──────────────┐
╱ ╲
╱ ╲
╱ 视锥体 ╲
╱ (Frustum) ╲
╱ ┌──────────────┐ ╲
╱ ╱ near plane ╲ ╲
│╱ [相机] ╲│
6.8.2 正交投影
正交投影没有透视效果——平行线保持平行,常用于 2D 渲染和 CAD:
float left = -1.0f;
float right = 1.0f;
float bottom = -1.0f;
float top = 1.0f;
float near = 0.1f;
float far = 100.0f;
glm::mat4 projection = glm::ortho(left, right, bottom, top, near, far);
| 特性 | 透视投影 | 正交投影 |
|---|---|---|
| 远近大小 | 近大远小 | 等大 |
| 平行线 | 汇聚于消失点 | 保持平行 |
| 用途 | 3D 游戏、场景渲染 | 2D UI、CAD、等距视角 |
6.8.3 近裁剪面的注意事项
⚠️ near 值不能设为 0:当 near→0 时,深度缓冲的精度急剧下降(Z-fighting)。建议 near = 0.1,far = 100~1000。
| near | far | 深度精度分布 |
|---|---|---|
| 0.001 | 1000 | 几乎所有精度集中在 0~1 范围 |
| 0.1 | 100 | 合理分布 |
| 1.0 | 1000 | 稍远处精度更高 |
6.9 MVP 矩阵组合
// 顶点着色器
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main() {
gl_Position = projection * view * model * vec4(aPos, 1.0);
}
// C++ 端
glm::mat4 model = glm::mat4(1.0f);
glm::mat4 view = glm::lookAt(...);
glm::mat4 projection = glm::perspective(...);
shader.setMat4("model", model);
shader.setMat4("view", view);
shader.setMat4("projection", projection);
6.9.1 矩阵应用顺序
代码书写顺序: projection → view → model → vertex
实际变换顺序: vertex → model → view → projection
局部坐标 世界坐标 相机坐标 裁剪坐标
矩阵乘法: gl_Position = P * V * M * vec4(aPos, 1.0);
←── 从右往左应用 ──┘
6.10 法线变换
当模型经过非均匀缩放时,法线向量不能直接用模型矩阵变换,需要使用法线矩阵(Normal Matrix):
glm::mat3 normalMatrix = glm::mat3(glm::transpose(glm::inverse(model)));
// 顶点着色器
uniform mat3 normalMatrix;
void main() {
vNormal = normalMatrix * aNormal;
}
💡 法线矩阵 = 模型矩阵左上 3×3 的逆转置。均匀缩放时可以直接用模型矩阵的左上 3×3。
6.11 完整示例:旋转的立方体
// 核心渲染代码
while (!glfwWindowShouldClose(window)) {
float time = glfwGetTime();
// 模型矩阵:持续旋转
glm::mat4 model = glm::mat4(1.0f);
model = glm::rotate(model, time * glm::radians(50.0f), glm::vec3(0.5f, 1.0f, 0.0f));
// 视图矩阵:固定相机
glm::mat4 view = glm::lookAt(
glm::vec3(0.0f, 2.0f, 3.0f), // 相机位置
glm::vec3(0.0f, 0.0f, 0.0f), // 看向原点
glm::vec3(0.0f, 1.0f, 0.0f) // 上方向
);
// 投影矩阵
glm::mat4 projection = glm::perspective(
glm::radians(45.0f), 800.0f / 600.0f, 0.1f, 100.0f
);
// 设置 Uniform
shader.use();
shader.setMat4("model", model);
shader.setMat4("view", view);
shader.setMat4("projection", projection);
// 绘制
glBindVertexArray(cubeVAO);
glDrawArrays(GL_TRIANGLES, 0, 36);
}
6.12 注意事项
⚠️ 矩阵乘法不满足交换律:
A × B ≠ B × A。在 OpenGL 中,变换顺序是投影 × 视图 × 模型 × 顶点。
⚠️ 弧度 vs 角度:GLM 的
glm::rotate接受弧度。使用glm::radians()转换。
⚠️ 每帧重新创建矩阵:不要缓存 MVP 矩阵。每帧重新计算以响应相机移动和物体动画。
⚠️ far 值不要太大:far = 10000 会严重降低深度精度,导致 Z-fighting。使用对数深度缓冲或减小 far 值。
6.13 业务场景
场景 1:建筑漫游
FPS 相机 + 透视投影实现第一人称建筑漫游。
场景 2:CAD 软件
正交投影 + 轨道相机(Orbit Camera)实现无透视变形的技术制图。
场景 3:小地图
主视图使用透视投影渲染 3D 场景,右下角用正交投影渲染俯视小地图。
6.14 扩展阅读
| 资源 | 说明 |
|---|---|
| 3D Math Primer for Graphics | 图形学数学入门 |
| GLM 文档 | GLM 库完整文档 |
| Song Ho 的 OpenGL 教程 | 变换矩阵详解 |
| 线性代数的本质 | 3Blue1Brown 视频 |
本章小结
- 3D→2D 变换链:模型空间 → 世界空间 → 观察空间 → 裁剪空间 → 屏幕空间
- 齐次坐标 (x,y,z,w) 使平移可以用矩阵乘法表示
- MVP 矩阵:gl_Position = Projection × View × Model × aPos
- GLM 是 OpenGL 最常用的数学库,API 风格与 GLSL 一致
- 透视投影模拟人眼(3D 游戏),正交投影无透视效果(2D/CAD)
- 法线需要使用模型矩阵逆转置来正确变换
上一章:第 5 章:纹理映射 下一章:第 7 章:光照与阴影