3计算机图形学原理及实践:基础方法(第3版)

几何变换

变换与投影

仿射变换齐次坐标投影变换

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第3章 几何变换(Geometric Transformations)

一、导读

1.1 本章学习目标

几何变换是计算机图形学的数学基石。从将三维模型放置到场景中,到从不同视角观察场景,再到将三维世界投影到二维屏幕——每一步都依赖几何变换。没有变换,我们就无法将一个局部坐标系中定义的模型移动到世界中的正确位置,也无法从任意角度观察场景。

本章的核心学习目标包括:

  • 掌握基本几何变换:深入理解平移、旋转、缩放三种基本变换的数学表示,以及它们的组合方式。
  • 理解齐次坐标的必要性:为什么需要引入齐次坐标?它如何统一了平移和线性变换的表示?齐次坐标的几何直觉是什么?
  • 掌握变换矩阵的组合:理解矩阵乘法的顺序意义,学会通过矩阵组合实现复杂的变换序列。
  • 理解三维空间中的变换:掌握绕任意轴旋转的公式、三维缩放和平移,以及万向锁问题的成因和解决方案。
  • 掌握观察变换:理解从世界坐标到相机坐标的变换过程,学会构造相机矩阵。
  • 深入理解投影变换:透视投影和正交投影的数学推导,视锥体的定义,以及透视投影矩阵的构造。

1.2 为什么本章如此重要

几何变换是渲染管线的数学基础。在顶点着色器中,最核心的工作就是对每个顶点执行 MVP(Model-View-Projection)变换——将顶点从模型空间变换到裁剪空间。如果你不理解这个过程的数学原理,就无法正确设置相机、无法实现物体运动、无法理解着色器中的坐标空间变换。

更重要的是,变换理论是理解高级图形技术的基础。骨骼动画中的蒙皮变换、阴影贴图中的光照空间变换、环境贴图中的反射向量计算——这些技术都建立在几何变换的基础之上。

齐次坐标和矩阵变换的思想不仅限于图形学,在机器人学、计算机视觉、物理学模拟等领域都有广泛应用。掌握这些数学工具,将为你打开更广阔的技术视野。

1.3 核心问题引导

在阅读本章时,请带着以下问题思考:

为什么平移不能用 3×3 矩阵表示?引入齐次坐标后,为什么就能用统一的矩阵形式表示所有变换?

矩阵乘法的顺序为什么重要?T * R 和 R * T 分别代表什么含义?如何直觉地理解"先应用的变换在右边"?

绕任意轴旋转的公式是如何推导的?Rodrigues 旋转公式的几何直觉是什么?

什么是万向锁(Gimbal Lock)?为什么欧拉角表示会导致这个问题?四元数如何解决它?

透视投影矩阵是如何将视锥体变换为单位立方体的?w 分量在透视投影中扮演什么角色?

正交投影和透视投影在数学上有什么本质区别?什么场景下应该使用正交投影?


二、核心概念详解

2.1 二维几何变换

2.1.1 平移(Translation)

平移是最简单的变换:将每个点沿指定方向移动固定距离。

平移公式:
x' = x + tx
y' = y + ty

用矩阵表示(齐次坐标):
┌ x' ┐   ┌ 1 0 tx ┐ ┌ x ┐
│ y' │ = │ 0 1 ty │ │ y │
└ 1  ┘   └ 0 0 1  ┘ └ 1 ┘

注意,在标准笛卡尔坐标中,平移不是线性变换(不满足 T(a+b) = T(a) + T(b)),因此不能用 2×2 矩阵表示。引入齐次坐标后,平移可以用 3×3 矩阵统一表示。

2.1.2 旋转(Rotation)

二维旋转将每个点绕原点旋转指定角度:

旋转公式:
x' = x·cos(θ) - y·sin(θ)
y' = x·sin(θ) + y·cos(θ)

矩阵表示:
┌ x' ┐   ┌ cos(θ)  -sin(θ)  0 ┐ ┌ x ┐
│ y' │ = │ sin(θ)   cos(θ)  0 │ │ y │
└ 1  ┘   └   0        0     1 ┘ └ 1 ┘
cpp
// 二维旋转矩阵
glm::mat3 rotate2D(float angle) {
  float c = std::cos(angle);
  float s = std::sin(angle);
  return glm::mat3(
    c, s, 0.0f,
    -s, c, 0.0f,
    0.0f, 0.0f, 1.0f
  );
}

// 绕任意点旋转(先平移到原点,旋转,再平移回去)
glm::mat3 rotateAroundPoint(float angle, glm::vec2 point) {
  glm::mat3 T1 = glm::mat3(
    1, 0, 0,
    0, 1, 0,
    -point.x, -point.y, 1
  );
  glm::mat3 R = glm::mat3(
    std::cos(angle), std::sin(angle), 0,
    -std::sin(angle), std::cos(angle), 0,
    0, 0, 1
  );
  glm::mat3 T2 = glm::mat3(
    1, 0, 0,
    0, 1, 0,
    point.x, point.y, 1
  );
  return T2 * R * T1;
}

2.1.3 缩放(Scaling)

缩放改变点与原点之间的距离:

缩放公式:
x' = x · sx
y' = y · sy

矩阵表示:
┌ x' ┐   ┌ sx  0  0 ┐ ┌ x ┐
│ y' │ = │  0 sy  0 │ │ y │
└ 1  ┘   └  0  0  1 ┘ └ 1 ┘

当 sx ≠ sy 时为不均匀缩放,会改变物体的形状比例;当 sx = sy 时为均匀缩放,保持形状不变。

cpp
// 绕任意点缩放
glm::mat3 scaleAroundPoint(glm::vec2 scale, glm::vec2 point) {
  glm::mat3 T1 = glm::mat3(1, 0, 0, 0, 1, 0, -point.x, -point.y, 1);
  glm::mat3 S = glm::mat3(scale.x, 0, 0, 0, scale.y, 0, 0, 0, 1);
  glm::mat3 T2 = glm::mat3(1, 0, 0, 0, 1, 0, point.x, point.y, 1);
  return T2 * S * T1;
}

2.1.4 其他二维变换

  • 错切(Shear):使物体沿一个方向"倾斜"
  • 反射(Reflection):关于某条直线或原点的镜像变换
cpp
// 错切矩阵
glm::mat3 shearX(float sh) {
  return glm::mat3(1, 0, 0, sh, 1, 0, 0, 0, 1);
}

// 关于 x 轴的反射
glm::mat3 reflectX() {
  return glm::mat3(1, 0, 0, 0, -1, 0, 0, 0, 1);
}

// 关于原点的反射
glm::mat3 reflectOrigin() {
  return glm::mat3(-1, 0, 0, 0, -1, 0, 0, 0, 1);
}

2.2 齐次坐标

2.2.1 为什么需要齐次坐标

在标准笛卡尔坐标中,平移、旋转、缩放三种变换的数学形式不统一:旋转和缩放是线性变换(可以用矩阵乘法表示),但平移不是(需要矩阵加法)。这种不统一带来了实际问题:

  • 无法用单个矩阵表示平移
  • 无法将多个变换组合为一个矩阵
  • 无法统一处理点和向量(点在平移下变化,向量不变)

齐次坐标通过增加一个维度解决了所有问题:

二维齐次坐标:(x, y, w) 对应笛卡尔坐标 (x/w, y/w)
三维齐次坐标:(x, y, z, w) 对应笛卡尔坐标 (x/w, y/w, z/w)

点:w = 1,表示为 (x, y, z, 1)
向量:w = 0,表示为 (x, y, z, 0)

关键性质:
- 点 + 向量 = 点(平移后还是点)
- 向量 + 向量 = 向量
- 点 - 点 = 向量
- 平移对向量无影响(因为 w=0,平移分量乘以 0)

2.2.2 齐次坐标的几何直觉

齐次坐标可以理解为"投影空间"中的表示。在投影空间中,(x, y, w) 和 (kx, ky, kw)(k≠0)表示同一个点——它们对应于笛卡尔空间中的同一个点 (x/w, y/w)。w 分量可以理解为"权重"或"缩放因子"。

cpp
// 齐次坐标的归一化
glm::vec4 homogeneousToCartesian(glm::vec4 h) {
  if (std::abs(h.w) > 1e-8f) {
    return glm::vec4(h.x / h.w, h.y / h.w, h.z / h.w, 1.0f);
  }
  return h; // w ≈ 0 表示方向向量或无穷远点
}

2.3 三维几何变换

2.3.1 三维基本变换

三维变换使用 4×4 齐次矩阵,包含平移、旋转(绕 X/Y/Z 轴)和缩放:

cpp
// 三维平移矩阵
glm::mat4 translate3D(float tx, float ty, float tz) {
  return glm::mat4(
    1, 0, 0, 0,
    0, 1, 0, 0,
    0, 0, 1, 0,
    tx, ty, tz, 1
  );
}

// 绕 X 轴旋转
glm::mat4 rotateX(float angle) {
  float c = std::cos(angle), s = std::sin(angle);
  return glm::mat4(
    1, 0,  0, 0,
    0, c,  s, 0,
    0, -s, c, 0,
    0, 0,  0, 1
  );
}

// 绕 Y 轴旋转
glm::mat4 rotateY(float angle) {
  float c = std::cos(angle), s = std::sin(angle);
  return glm::mat4(
    c, 0, -s, 0,
    0, 1,  0, 0,
    s, 0,  c, 0,
    0, 0,  0, 1
  );
}

// 绕 Z 轴旋转
glm::mat4 rotateZ(float angle) {
  float c = std::cos(angle), s = std::sin(angle);
  return glm::mat4(
    c,  s, 0, 0,
    -s, c, 0, 0,
    0,  0, 1, 0,
    0,  0, 0, 1
  );
}

// 三维缩放
glm::mat4 scale3D(float sx, float sy, float sz) {
  return glm::mat4(
    sx, 0,  0,  0,
    0,  sy, 0,  0,
    0,  0,  sz, 0,
    0,  0,  0,  1
  );
}

2.3.2 绕任意轴旋转

绕任意轴旋转是三维图形学中的重要操作。Rodrigues 旋转公式给出了绕单位向量 k 旋转角度 θ 的旋转矩阵:

R = cos(θ)·I + sin(θ)·[k]× + (1-cos(θ))·(k⊗k)

其中 [k]× 是 k 的叉积矩阵:
        ┌  0   -kz   ky ┐
[k]× =  │  kz   0   -kx │
        └ -ky   kx   0  ┘

k⊗k 是 k 的外积矩阵
cpp
// Rodrigues 旋转公式实现
glm::mat4 rotateAroundAxis(glm::vec3 axis, float angle) {
  axis = glm::normalize(axis);
  float c = std::cos(angle);
  float s = std::sin(angle);
  float t = 1.0f - c;
  float x = axis.x, y = axis.y, z = axis.z;

  return glm::mat4(
    t*x*x + c,    t*x*y - s*z,  t*x*z + s*y,  0,
    t*x*y + s*z,  t*y*y + c,    t*y*z - s*x,  0,
    t*x*z - s*y,  t*y*z + s*x,  t*z*z + c,    0,
    0,            0,            0,            1
  );
}

2.3.3 万向锁与四元数

万向锁(Gimbal Lock) 是欧拉角表示旋转时的固有缺陷。当两个旋转轴对齐时,系统失去一个自由度,导致某些方向上的旋转无法实现。

万向锁示意:

正常状态:           万向锁状态:
  Y                   Y
  ↑                   ↑
  │     Z             │
  │   /               │
  │ /                 │
  └──→ X              └──→ X
  三个轴互相垂直        Y 和 Z 轴对齐
  三个自由度           只剩两个自由度

四元数是解决万向锁问题的标准方案。四元数 q = w + xi + yj + zk 用四个参数表示三维旋转,具有插值平滑、无万向锁、组合高效等优点。

cpp
// 四元数基本操作
struct Quaternion {
  float w, x, y, z;

  static Quaternion fromAxisAngle(glm::vec3 axis, float angle) {
    float halfAngle = angle * 0.5f;
    float s = std::sin(halfAngle);
    axis = glm::normalize(axis);
    return {std::cos(halfAngle), axis.x * s, axis.y * s, axis.z * s};
  }

  static Quaternion fromEuler(float pitch, float yaw, float roll) {
    float cp = std::cos(pitch * 0.5f), sp = std::sin(pitch * 0.5f);
    float cy = std::cos(yaw * 0.5f), sy = std::sin(yaw * 0.5f);
    float cr = std::cos(roll * 0.5f), sr = std::sin(roll * 0.5f);

    return {
      cr * cp * cy + sr * sp * sy,
      sr * cp * cy - cr * sp * sy,
      cr * sp * cy + sr * cp * sy,
      cr * cp * sy - sr * sp * cy
    };
  }

  Quaternion operator*(const Quaternion& q) const {
    return {
      w*q.w - x*q.x - y*q.y - z*q.z,
      w*q.x + x*q.w + y*q.z - z*q.y,
      w*q.y - x*q.z + y*q.w + z*q.x,
      w*q.z + x*q.y - y*q.x + z*q.w
    };
  }

  glm::mat4 toMatrix() const {
    float xx = x*x, yy = y*y, zz = z*z;
    float xy = x*y, xz = x*z, yz = y*z;
    float wx = w*x, wy = w*y, wz = w*z;

    return glm::mat4(
      1 - 2*(yy+zz), 2*(xy+wz),   2*(xz-wy),   0,
      2*(xy-wz),     1 - 2*(xx+zz), 2*(yz+wx),   0,
      2*(xz+wy),     2*(yz-wx),   1 - 2*(xx+yy), 0,
      0,             0,           0,           1
    );
  }

  // 球面线性插值(SLERP)
  static Quaternion slerp(const Quaternion& a, const Quaternion& b, float t) {
    float dot = a.w*b.w + a.x*b.x + a.y*b.y + a.z*b.z;
    Quaternion b2 = b;
    if (dot < 0) {
      b2 = {-b.w, -b.x, -b.y, -b.z};
      dot = -dot;
    }
    if (dot > 0.9995f) {
      // 角度很小,使用线性插值
      return {a.w + t*(b2.w-a.w), a.x + t*(b2.x-a.x),
              a.y + t*(b2.y-a.y), a.z + t*(b2.z-a.z)};
    }
    float theta = std::acos(dot);
    float sinTheta = std::sin(theta);
    float wa = std::sin((1-t)*theta) / sinTheta;
    float wb = std::sin(t*theta) / sinTheta;
    return {wa*a.w + wb*b2.w, wa*a.x + wb*b2.x,
            wa*a.y + wb*b2.y, wa*a.z + wb*b2.z};
  }
};

2.4 变换的组合

2.4.1 矩阵乘法的顺序

变换的组合通过矩阵乘法实现,但矩阵乘法不满足交换律,因此顺序至关重要:

变换顺序(从右到左应用):
v' = T * R * S * v

含义:先缩放(S),再旋转(R),最后平移(T)

为什么这个顺序很重要?
- 如果先平移再旋转:物体会绕原点公转,而不是自转
- 如果先平移再缩放:缩放会影响平移距离
cpp
// 正确的变换顺序:缩放 → 旋转 → 平移
glm::mat4 model = glm::mat4(1.0f);
model = glm::translate(model, glm::vec3(1.0f, 0.0f, 0.0f));
model = glm::rotate(model, glm::radians(45.0f), glm::vec3(0, 1, 0));
model = glm::scale(model, glm::vec3(2.0f, 2.0f, 2.0f));

// 等价于:model = T * R * S
// 应用到顶点:v' = T * R * S * v = T * (R * (S * v))
// 即先缩放,再旋转,最后平移

// 错误顺序示例
glm::mat4 wrongModel = glm::mat4(1.0f);
wrongModel = glm::scale(wrongModel, glm::vec3(2.0f));
wrongModel = glm::rotate(wrongModel, glm::radians(45.0f), glm::vec3(0, 1, 0));
wrongModel = glm::translate(wrongModel, glm::vec3(1.0f, 0.0f, 0.0f));
// 等价于:wrongModel = S * R * T
// 先平移,再旋转(物体会绕原点公转),最后缩放(平移距离也被缩放)

2.4.2 层次变换

在场景图中,子节点继承父节点的变换。世界变换矩阵是从根到当前节点的所有局部变换矩阵的乘积:

cpp
// 层次变换
struct TransformNode {
  glm::mat4 localTransform;
  glm::mat4 worldTransform;
  std::shared_ptr<TransformNode> parent;

  void updateWorldTransform() {
    if (parent) {
      worldTransform = parent->worldTransform * localTransform;
    } else {
      worldTransform = localTransform;
    }
  }
};

// 示例:太阳系模型
// 地球绕太阳公转,月球绕地球公转
// 太阳(根节点)
glm::mat4 sunWorld = glm::mat4(1.0f);

// 地球:先平移到轨道位置,再自转
glm::mat4 earthLocal = glm::mat4(1.0f);
earthLocal = glm::rotate(earthLocal, earthOrbitAngle, glm::vec3(0, 1, 0));
earthLocal = glm::translate(earthLocal, glm::vec3(earthOrbitRadius, 0, 0));
earthLocal = glm::rotate(earthLocal, earthSpinAngle, glm::vec3(0, 1, 0));
glm::mat4 earthWorld = sunWorld * earthLocal;

// 月球:继承地球的世界变换,再加上自己的轨道和自转
glm::mat4 moonLocal = glm::mat4(1.0f);
moonLocal = glm::rotate(moonLocal, moonOrbitAngle, glm::vec3(0, 1, 0));
moonLocal = glm::translate(moonLocal, glm::vec3(moonOrbitRadius, 0, 0));
glm::mat4 moonWorld = earthWorld * moonLocal;

2.5 观察变换

2.5.1 相机模型

观察变换将世界坐标变换到相机(视图)坐标。相机由三个参数定义:

  • 位置(eye):相机在世界中的位置
  • 目标(target):相机观察的目标点
  • 上方向(up):相机的"上"方向
cpp
// 构造观察矩阵(lookAt)
glm::mat4 lookAt(glm::vec3 eye, glm::vec3 target, glm::vec3 up) {
  glm::vec3 f = glm::normalize(target - eye); // 前方向
  glm::vec3 r = glm::normalize(glm::cross(f, up)); // 右方向
  glm::vec3 u = glm::cross(r, f); // 真正的上方向

  glm::mat4 view(1.0f);
  view[0][0] = r.x;  view[1][0] = r.y;  view[2][0] = r.z;
  view[0][1] = u.x;  view[1][1] = u.y;  view[2][1] = u.z;
  view[0][2] = -f.x; view[1][2] = -f.y; view[2][2] = -f.z;
  view[3][0] = -glm::dot(r, eye);
  view[3][1] = -glm::dot(u, eye);
  view[3][2] =  glm::dot(f, eye);
  return view;
}

// 使用 GLM 库
glm::mat4 view = glm::lookAt(
  glm::vec3(0.0f, 2.0f, 5.0f),  // 相机位置
  glm::vec3(0.0f, 0.0f, 0.0f),  // 观察目标
  glm::vec3(0.0f, 1.0f, 0.0f)   // 上方向
);

2.5.2 FPS 相机控制

cpp
class FPSCamera {
public:
  glm::vec3 position;
  float yaw;   // 水平旋转角
  float pitch; // 垂直旋转角

  FPSCamera(glm::vec3 pos)
    : position(pos), yaw(-90.0f), pitch(0.0f) {}

  glm::vec3 getFront() const {
    float yawRad = glm::radians(yaw);
    float pitchRad = glm::radians(pitch);
    return glm::normalize(glm::vec3(
      std::cos(pitchRad) * std::cos(yawRad),
      std::sin(pitchRad),
      std::cos(pitchRad) * std::sin(yawRad)
    ));
  }

  glm::vec3 getRight() const {
    return glm::normalize(glm::cross(getFront(), glm::vec3(0, 1, 0)));
  }

  glm::mat4 getViewMatrix() const {
    return glm::lookAt(position, position + getFront(), glm::vec3(0, 1, 0));
  }

  void processMouse(float xOffset, float yOffset, float sensitivity) {
    yaw += xOffset * sensitivity;
    pitch += yOffset * sensitivity;
    pitch = glm::clamp(pitch, -89.0f, 89.0f);
  }

  void processKeyboard(int direction, float deltaTime, float speed) {
    float velocity = speed * deltaTime;
    glm::vec3 front = getFront();
    glm::vec3 right = getRight();
    if (direction == FORWARD)  position += front * velocity;
    if (direction == BACKWARD) position -= front * velocity;
    if (direction == LEFT)     position -= right * velocity;
    if (direction == RIGHT)    position += right * velocity;
  }

private:
  enum { FORWARD, BACKWARD, LEFT, RIGHT };
};

2.6 投影变换

2.6.1 正交投影

正交投影将视景体(一个长方体)映射到单位立方体 [-1,1]³。平行线在正交投影下保持平行。

cpp
// 正交投影矩阵
glm::mat4 ortho(float left, float right,
                float bottom, float top,
                float zNear, float zFar) {
  glm::mat4 result(1.0f);
  result[0][0] = 2.0f / (right - left);
  result[1][1] = 2.0f / (top - bottom);
  result[2][2] = -2.0f / (zFar - zNear);
  result[3][0] = -(right + left) / (right - left);
  result[3][1] = -(top + bottom) / (top - bottom);
  result[3][2] = -(zFar + zNear) / (zFar - zNear);
  return result;
}

// GLM 版本
glm::mat4 projection = glm::ortho(-10.0f, 10.0f, -7.5f, 7.5f, 0.1f, 100.0f);

2.6.2 透视投影

透视投影将视锥体(截头金字塔)映射到单位立方体。与正交投影不同,透视投影产生近大远小的效果,更符合人眼的视觉感知。

透视投影的视锥体:

        远裁剪面
      ┌──────────┐
     /│          │\
    / │          │ \
   /  │          │  \
  /   │          │   \
 /    │          │    \
/─────┘──────────└─────\
  近裁剪面
    相机位置

视锥体由6个参数定义:
- FOV(视场角)
- 宽高比(aspect ratio)
- 近裁剪面距离(near)
- 远裁剪面距离(far)
cpp
// 透视投影矩阵推导
// 目标:将视锥体映射到 NDC 立方体 [-1,1]³
// 关键:透视除法(除以 w)实现近大远小

glm::mat4 perspective(float fovY, float aspect, float zNear, float zFar) {
  float tanHalfFov = std::tan(fovY / 2.0f);

  glm::mat4 result(0.0f);
  result[0][0] = 1.0f / (aspect * tanHalfFov);
  result[1][1] = 1.0f / tanHalfFov;
  result[2][2] = -(zFar + zNear) / (zFar - zNear);
  result[2][3] = -1.0f;
  result[3][2] = -(2.0f * zFar * zNear) / (zFar - zNear);
  return result;
}

// 透视投影的关键特性:
// 1. result[2][3] = -1 使得 w_clip = -z_view
// 2. 透视除法:x_ndc = x_clip / w_clip = x_clip / (-z_view)
// 3. 这个除法实现了近大远小的透视效果

2.6.3 MVP 变换的完整流程

cpp
// 完整的 MVP 变换
void renderObject(const Mesh& mesh, const glm::mat4& model,
                  const glm::mat4& view, const glm::mat4& projection) {
  glm::mat4 mvp = projection * view * model;

  // 在顶点着色器中:
  // gl_Position = uMVP * vec4(aPosition, 1.0);

  // 坐标空间变换链:
  // 模型空间 → 世界空间 → 相机空间 → 裁剪空间 → NDC → 屏幕空间
  //   model       view       projection   ÷w     viewport
}

// GLSL 顶点着色器
// #version 450 core
// layout(location = 0) in vec3 aPosition;
// uniform mat4 uModel;
// uniform mat4 uView;
// uniform mat4 uProjection;
// void main() {
//   gl_Position = uProjection * uView * uModel * vec4(aPosition, 1.0);
// }

三、实战案例

3.1 3D 场景渲染

以下代码展示了一个完整的 3D 场景渲染,包含模型变换、相机控制和投影:

cpp
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <vector>

struct Vertex {
  float position[3];
  float color[3];
};

class Scene3D {
public:
  void init() {
    // 立方体顶点数据
    std::vector<Vertex> vertices = {
      // 前面
      {{-0.5f, -0.5f,  0.5f}, {1, 0, 0}},
      {{ 0.5f, -0.5f,  0.5f}, {0, 1, 0}},
      {{ 0.5f,  0.5f,  0.5f}, {0, 0, 1}},
      {{-0.5f,  0.5f,  0.5f}, {1, 1, 0}},
      // 后面
      {{-0.5f, -0.5f, -0.5f}, {1, 0, 1}},
      {{ 0.5f, -0.5f, -0.5f}, {0, 1, 1}},
      {{ 0.5f,  0.5f, -0.5f}, {1, 1, 1}},
      {{-0.5f,  0.5f, -0.5f}, {0.5f, 0.5f, 0.5f}},
    };

    std::vector<unsigned int> indices = {
      0, 1, 2, 2, 3, 0,  // 前
      1, 5, 6, 6, 2, 1,  // 右
      5, 4, 7, 7, 6, 5,  // 后
      4, 0, 3, 3, 7, 4,  // 左
      3, 2, 6, 6, 7, 3,  // 上
      4, 5, 1, 1, 0, 4   // 下
    };

    glGenVertexArrays(1, &vao);
    glGenBuffers(1, &vbo);
    glGenBuffers(1, &ebo);

    glBindVertexArray(vao);
    glBindBuffer(GL_ARRAY_BUFFER, vbo);
    glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex),
                 vertices.data(), GL_STATIC_DRAW);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ebo);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int),
                 indices.data(), GL_STATIC_DRAW);

    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
                          (void*)0);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex),
                          (void*)(3 * sizeof(float)));
    glEnableVertexAttribArray(1);
  }

  void render(float time) {
    glUseProgram(shaderProgram);

    glm::mat4 view = camera.getViewMatrix();
    glm::mat4 projection = glm::perspective(
      glm::radians(45.0f), 800.0f / 600.0f, 0.1f, 100.0f
    );

    // 渲染多个立方体,每个有不同的变换
    for (int i = 0; i < 10; i++) {
      glm::mat4 model = glm::mat4(1.0f);
      float angle = 20.0f * i;
      model = glm::rotate(model, glm::radians(angle),
                          glm::vec3(1.0f, 0.3f, 0.5f));
      model = glm::translate(model,
        glm::vec3(std::sin(time + i) * 3.0f, 0.0f,
                  std::cos(time + i) * 3.0f));
      model = glm::rotate(model, time + glm::radians(float(i) * 50.0f),
                          glm::vec3(0.5f, 1.0f, 0.0f));

      glm::mat4 mvp = projection * view * model;
      glUniformMatrix4fv(glGetUniformLocation(shaderProgram, "uMVP"),
                         1, GL_FALSE, glm::value_ptr(mvp));

      glBindVertexArray(vao);
      glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_INT, 0);
    }
  }

private:
  GLuint vao, vbo, ebo;
  GLuint shaderProgram;
  FPSCamera camera{glm::vec3(0.0f, 2.0f, 8.0f)};
};

3.2 坐标空间可视化

以下工具函数帮助理解各坐标空间的变换过程:

cpp
// 坐标空间调试工具
void printTransformChain(glm::vec3 modelPos,
                         const glm::mat4& model,
                         const glm::mat4& view,
                         const glm::mat4& projection) {
  glm::vec4 pos = glm::vec4(modelPos, 1.0f);
  std::cout << "模型空间: (" << pos.x << ", " << pos.y << ", "
            << pos.z << ")" << std::endl;

  pos = model * pos;
  std::cout << "世界空间: (" << pos.x << ", " << pos.y << ", "
            << pos.z << ")" << std::endl;

  pos = view * pos;
  std::cout << "相机空间: (" << pos.x << ", " << pos.y << ", "
            << pos.z << ")" << std::endl;

  pos = projection * pos;
  std::cout << "裁剪空间: (" << pos.x << ", " << pos.y << ", "
            << pos.z << ", " << pos.w << ")" << std::endl;

  // 透视除法
  glm::vec3 ndc = glm::vec3(pos) / pos.w;
  std::cout << "NDC: (" << ndc.x << ", " << ndc.y << ", "
            << ndc.z << ")" << std::endl;

  // 视口变换
  float screenX = (ndc.x + 1.0f) * 0.5f * 800.0f;
  float screenY = (ndc.y + 1.0f) * 0.5f * 600.0f;
  float screenZ = (ndc.z + 1.0f) * 0.5f;
  std::cout << "屏幕坐标: (" << screenX << ", " << screenY << ", "
            << screenZ << ")" << std::endl;
}

四、本章小结

本章系统讲解了计算机图形学中的几何变换理论和实践:

基本变换(平移、旋转、缩放)是所有复杂变换的基础。在齐次坐标框架下,它们都可以用矩阵乘法统一表示。

齐次坐标是图形学中最重要的数学工具之一。它不仅统一了平移和线性变换的表示,还区分了点(w=1)和向量(w=0)的语义。

变换组合通过矩阵乘法实现,顺序至关重要。标准的模型变换顺序是"缩放→旋转→平移"。

万向锁是欧拉角表示的固有缺陷,四元数通过四维空间中的球面插值避免了这个问题,是现代图形引擎中旋转表示的首选方案。

观察变换将世界坐标变换到相机坐标,本质是构造一个以相机为原点的坐标系。

投影变换将三维场景映射到二维屏幕。透视投影通过透视除法(除以 w)实现近大远小的效果,是最符合人眼视觉的投影方式。


五、思考与练习

思考题

证明:两个旋转矩阵的乘积仍然是旋转矩阵。旋转矩阵的逆矩阵等于它的转置吗?为什么?

如果一个物体的模型矩阵是 M = T(1,0,0) * R(45°, Y) * S(2,2,2),那么物体的中心在世界坐标中的位置是什么?物体的一个局部顶点 (1,0,0) 在世界坐标中的位置是什么?

透视投影中,w 分量为什么被设为 -z_view?如果设为 +z_view 会怎样?

四元数的 SLERP 插值为什么比欧拉角的线性插值更好?在什么场景下差异最明显?

正交投影在什么场景下比透视投影更合适?请举出至少三个例子。

编程练习

四元数相机:用四元数实现 FPS 相机控制,对比与欧拉角实现在极端角度下的行为差异。

轨道相机:实现一个围绕目标点旋转的轨道相机,支持鼠标拖拽旋转、滚轮缩放。

坐标系可视化:用 OpenGL 绘制三个坐标轴的箭头(X红、Y绿、Z蓝),显示每个变换后的坐标空间方向。


3/8几何变换
0%