空间变换

空间变换这一节,我原本准备就写一篇文章的,但是发现自己陌生的知识点太多了,拆分成如下几篇额外的“预备”文章。

1.《LU 分解和矩阵实现》 文章中,我们自己实现了一套矩阵代码,特别关注矩阵求逆。

2.《罗德里格旋转公式》 文章中,我们专门讲解绕任意轴旋转。

3.《正交投影矩阵和透视投影矩阵》 文章中,我们专门讲解投影变换。对应本篇文章中第四小节这个阶段。

如果你和我一样,之前写 OpenGL 程序时,对涉及到变换矩阵的点,有些含糊不清的话。那么这篇文章之后,就会有一个更加清晰的认识。因为我们也要实现一个旋转立方体。

1. 空间坐标系统

在相关书籍中,会看到各种坐标,包括世界坐标、模型坐标、视图坐标、裁剪坐标和屏幕坐标。看着非常绕。我们此时先了解世界坐标,其他坐标跟随后续变换矩阵进行理解。

世界坐标是一个全局的参考系统,允许场景中的所有元素(比如物体和摄像机)在一个统一的空间中被定位和参照。

世界坐标是“假想”的,你需要自己定义。我们在 《正交投影矩阵和透视投影矩阵》 文章中已经了解到,如果视图平面设置成对称的,那么变换矩阵的形式会更加简洁。而且近平面通常就是映射到我们应用窗体的大小。

所以我们设置世界坐标原点在窗体的中心处。并且我们使用左手坐标系,即 z 轴正方向朝屏幕内。

我这边的理解:世界坐标可以自定义,简化后续需要的操作步骤。

2. 模型矩阵

模型矩阵用于定义场景中每个对象的位置、旋转和缩放。即模型矩阵将模型对象从它们的局部坐标系转化到全局的世界坐标系。

模型矩阵可以单独或组合使用平移、旋转、缩放矩阵,来实现复杂的变换。我们来看各个变换矩阵。

平移矩阵用于将物体沿 x、y、z 轴移动指定的距离。表示为

\( T= \begin{pmatrix} 1 & 0 & 0 & t_x \\ 0 & 1 & 0 & t_y \\ 0 & 0 & 1 & t_z \\ 0 & 0 & 0 & 1 \end{pmatrix} \)

缩放矩阵用于改变物体在 x、y、z 轴方向上的尺寸。表示为

\( S= \begin{pmatrix} s_x & 0 & 0 & 0 \\ 0 & s_y & 0 & 0 \\ 0 & 0 & s_z & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \)

旋转矩阵的话,我们使用可以沿任意轴旋转的矩阵。形式比较复杂,详细推导见 《罗德里格旋转公式》。表示为

\( R= \begin{pmatrix} k_x^2(1-\text{cos}\theta)+\text{cos}\theta & k_xk_y(1-\text{cos}\theta)-k_z\text{sin}\theta & k_xk_z(1-\text{cos}\theta)+k_y\text{sin}\theta \\ k_xk_y(1-\text{cos}\theta)+k_z\text{sin}\theta & k_y^2(1-\text{cos}\theta)+\text{cos}\theta & k_yk_z(1-\text{cos}\theta)-k_x\text{sin}\theta \\ k_xk_z(1-\text{cos}\theta)-k_y\text{sin}\theta & k_yk_z(1-\text{cos}\theta)+k_x\text{sin}\theta & k_z^2(1-\text{cos}\theta)+\text{cos}\theta \end{pmatrix} \)

结合以上,我们能了解模型坐标空间。

3. 视图矩阵

视图变换和模型变换是类似的,只不过变换的对象从模型变成了摄像机。操作同样是平移、旋转(缩放摄像机就不必了)。

但是这边遇到一个问题,我们后续计算,总是希望摄像头位于世界坐标原点。所以我们对摄像机的变换矩阵求逆,即把变换作用到模型物体上。

这个容易理解:比如,物体不动,摄像头向 x 轴正方向移动;相对的相同效果就是,摄像头不动,物体向 x 轴负方向移动。

我们首先定义需要的变量:观察点位置 eye,即摄像机的位置;目标点位置 center,即摄像机看向的点;上向量 up,即世界坐标中向上的方向,通常为 (0,1,0)。

我们按照先旋转再平移的思路进行。首先看到旋转,旋转的思路就是改变基坐标。我们根据以上参数求得各个新的坐标基。

新的 z 轴设置为摄像机视线向量,即前向量 \(\textbf{f}=\frac{\textbf{center}-\textbf{eye}}{\lvert \textbf{center}-\textbf{eye} \rvert}\)。

根据和 up 向量叉乘,得到新的 x 轴,即右向量 \(\textbf{r}=\frac{\textbf{up}\times \textbf{f}}{\lvert \textbf{up}\times \textbf{f} \rvert}\)。

然后,得到准确的上向量,作为 y 轴,即 \(\textbf{u}=\textbf{f} \times \textbf{r}\)。

这样,我们就可以得到旋转矩阵,其实就是基向量矩阵。

\( R= \begin{pmatrix} \textbf{r}_x & \textbf{u}_x & \textbf{f}_x & 0 \\ \textbf{r}_y & \textbf{u}_y & \textbf{f}_y & 0 \\ \textbf{r}_z & \textbf{u}_z & \textbf{f}_z & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \)

因为基向量矩阵是一个正交矩阵,所以 \(S^{-1}=S^{T}\)。

正交矩阵是所有行向量和列向量彼此垂直,且所有行列向量是单位向量。

因为两个垂直向量点乘为 0,自身单位向量点乘为 1。

所以 \(A^{T}A=AA^{T}=I\),即 \(A^{-1}=A^{T}\)。

还有一个问题是,基向量矩阵为什么是旋转矩阵。

绕 X 轴旋转: \( R_x(\theta)= \begin{pmatrix} 1 & 0 & 0 \\ 0 & cos(\theta) & -sin(\theta) \\ 0 & sin(\theta) & cos(\theta) \\ \end{pmatrix} \)

绕 Y 轴旋转: \( R_y(\theta)= \begin{pmatrix} cos(\theta) & 0 & sin(\theta) \\ 0 & 1 & 0 \\ -sin(\theta) & 0 & cos(\theta) \\ \end{pmatrix} \)

绕 Z 轴旋转: \( R_z(\theta)= \begin{pmatrix} cos(\theta) & -sin(\theta) & 0 \\ sin(\theta) & cos(\theta) & 0 \\ 0 & 0 & 1 \\ \end{pmatrix} \)

我们令 \(R=R_x(\alpha)R_y(\beta)R_z(\gamma)\),

同样有 \(R^TR=RR^T=I\),即旋转矩阵也是正交矩阵。

摄像机旋转完成了,我们再移动它,有平移矩阵

\( T= \begin{pmatrix} 1 & 0 & 0 & \textbf{eye}_x \\ 0 & 1 & 0 & \textbf{eye}_y \\ 0 & 0 & 1 & \textbf{eye}_z \\ 0 & 0 & 0 & 1 \end{pmatrix} \)

最终我们有视图矩阵

\(V=(TR)^{-1}=R^{-1}T^{-1}\)

\(= \begin{pmatrix} \textbf{r}_x & \textbf{r}_y & \textbf{r}_z & 0 \\ \textbf{u}_x & \textbf{u}_y & \textbf{u}_z & 0 \\ \textbf{f}_x & \textbf{f}_y & \textbf{f}_z & 0 \\ 0 & 0 & 0 & 1 \end{pmatrix} \begin{pmatrix} 1 & 0 & 0 & -\textbf{eye}_x \\ 0 & 1 & 0 & -\textbf{eye}_y \\ 0 & 0 & 1 & -\textbf{eye}_z \\ 0 & 0 & 0 & 1 \end{pmatrix} \)

\( = \begin{pmatrix} \textbf{r}_x & \textbf{r}_y & \textbf{r}_z & -\textbf{r}\cdot\textbf{eye} \\ \textbf{u}_x & \textbf{u}_y & \textbf{u}_z & -\textbf{u}\cdot\textbf{eye} \\ \textbf{f}_x & \textbf{f}_y & \textbf{f}_z & -\textbf{f}\cdot\textbf{eye} \\ 0 & 0 & 0 & 1 \end{pmatrix} \)

结合以上,我们能了解视图坐标空间。

4. 投影矩阵

投影矩阵我们已经在 《正交投影矩阵和透视投影矩阵》 文章中进行了详细讲解。

投影矩阵将坐标转换到 NDC 空间,即裁剪坐标空间。

5. 屏幕矩阵

因为我们画点是基于窗体屏幕上的像素坐标,所以我们需要把 NDC 空间映射到屏幕坐标。

映射操作相较以上其他操作比较简单,我们指定屏幕的宽高 width 和 height。

针对 x,我们进行映射 \([-1,1]\rightarrow [0,\textbf{width}]\)。

针对 y,需要注意,因为在我们的代码环境中,屏幕原点在左上角,y 轴向下增长,所以进行映射 \([1,-1]\rightarrow [0,\textbf{height}]\)。

所以我们得到屏幕矩阵

\( \begin{pmatrix} \frac{\textbf{width}}{2} & 0 & 0 & \frac{\textbf{width}}{2} \\ 0 & -\frac{\textbf{height}}{2} & 0 & \frac{\textbf{height}}{2} \\ 0 & 0 & 1 & 0\\ 0 & 0 & 0 & 1 \end{pmatrix} \)

6. 实验:旋转立方体

经过了如此长的铺垫,我们实现一个完整的例子,展示这些矩阵是如何一起作用的。

我们简单梳理一下流程,在写 OpenGL 程序的时候,会看到有一个 MVP 矩阵。这边的 M 是 Model,即我们把模型坐标转换到世界坐标;接着 V 是 View,即我们把场景从世界坐标转换到摄像机视角下的视图坐标;接着 P 是 Projection,我们还需要进行投影,将视图坐标转换到裁剪坐标。最后我们需要把裁剪坐标转换成屏幕上的像素坐标。

我们看到代码清单 1,其中我们给定了模型局部坐标,对应变量 m_cubeVertices,是 36 个顶点,12 个三角形,6 个面,组成一个立方体。投影矩阵和屏幕矩阵是固定的,我们在构造函数里进行初始化。

代码清单 1 初始化
  1. TViewTransformRenderTask::TViewTransformRenderTask(TBasicWindow& win)
  2.     : m_angle(0), m_cameraPos(-5.0f)
  3. {
  4.     int width = win.GetWindowWidth();
  5.     int height = win.GetWindowHeight();
  6.  
  7.     float aspect = (float)width / height;
  8.     m_screenMatrix = tmath::ScreenMatrix<float>(width, height);
  9.     m_perspectiveMatrix = tmath::PerspectiveMatrix(tmath::degToRad(60.0f), aspect, 0.1f, 100.0f);
  10.     m_orthographicMatrix = tmath::OrthographicMatrix(-3 * aspect, 3 * aspect, -3.0f, 3.0f, 0.1f, 100.0f);
  11.  
  12.     m_cubeVertices = { {
  13.         {-0.5f, -0.5f, -0.5f, 1.0f},
  14.         { 0.5f, -0.5f, -0.5f, 1.0f},
  15.         { 0.5f,  0.5f, -0.5f, 1.0f},
  16.         { 0.5f,  0.5f, -0.5f, 1.0f},
  17.         {-0.5f,  0.5f, -0.5f, 1.0f},
  18.         {-0.5f, -0.5f, -0.5f, 1.0f},
  19.  
  20.         {-0.5f, -0.5f,  0.5f, 1.0f},
  21.         { 0.5f, -0.5f,  0.5f, 1.0f},
  22.         { 0.5f,  0.5f,  0.5f, 1.0f},
  23.         { 0.5f,  0.5f,  0.5f, 1.0f},
  24.         {-0.5f,  0.5f,  0.5f, 1.0f},
  25.         {-0.5f, -0.5f,  0.5f, 1.0f},
  26.  
  27.         {-0.5f,  0.5f,  0.5f, 1.0f},
  28.         {-0.5f,  0.5f, -0.5f, 1.0f},
  29.         {-0.5f, -0.5f, -0.5f, 1.0f},
  30.         {-0.5f, -0.5f, -0.5f, 1.0f},
  31.         {-0.5f, -0.5f,  0.5f, 1.0f},
  32.         {-0.5f,  0.5f,  0.5f, 1.0f},
  33.  
  34.         {0.5f,  0.5f,  0.5f, 1.0f},
  35.         {0.5f,  0.5f, -0.5f, 1.0f},
  36.         {0.5f, -0.5f, -0.5f, 1.0f},
  37.         {0.5f, -0.5f, -0.5f, 1.0f},
  38.         {0.5f, -0.5f,  0.5f, 1.0f},
  39.         {0.5f,  0.5f,  0.5f, 1.0f},
  40.  
  41.         {-0.5f, -0.5f, -0.5f, 1.0f},
  42.         { 0.5f, -0.5f, -0.5f, 1.0f},
  43.         { 0.5f, -0.5f,  0.5f, 1.0f},
  44.         { 0.5f, -0.5f,  0.5f, 1.0f},
  45.         {-0.5f, -0.5f,  0.5f, 1.0f},
  46.         {-0.5f, -0.5f, -0.5f, 1.0f},
  47.  
  48.         {-0.5f,  0.5f, -0.5f, 1.0f},
  49.         { 0.5f,  0.5f, -0.5f, 1.0f},
  50.         { 0.5f,  0.5f,  0.5f, 1.0f},
  51.         { 0.5f,  0.5f,  0.5f, 1.0f},
  52.         {-0.5f,  0.5f,  0.5f, 1.0f},
  53.         {-0.5f,  0.5f, -0.5f, 1.0f},
  54.     } };
  55. }

接着,如代码清单 2 所示,就是空间变换的主要部分。我们每次渲染把模型绕对角线旋转一定角度;视角为平视,并且逐步往后退,可以利用第三节中讲的视图矩阵,也可以直接通过平移矩阵得到;作用上透视矩阵后,我们进行透视除法;最后应用屏幕矩阵。

代码清单 2 空间变换
  1. void TViewTransformRenderTask::Transform()
  2. {
  3.     m_angle += 0.01f;
  4.     m_cameraPos -= 0.002f;
  5.     m_modelMatrix = tmath::RotationMatrix(tmath::Vec3f(1.0f, 1.0f, 1.0f), m_angle);
  6.     //m_viewMatrix = tmath::LookAtMatrix(tmath::Vec3f(0, 0, m_cameraPos), tmath::Vec3f(0, 0, 0), tmath::Vec3f(0, 1, 0));
  7.     m_viewMatrix = tmath::TranslationMatrix(0.0f, 0.0f, m_cameraPos).Inverse();
  8.  
  9.     tmath::Vec4f vec;
  10.     for (int i = 0; i < 36; i++)
  11.     {
  12.         vec = m_perspectiveMatrix * m_viewMatrix * m_modelMatrix * m_cubeVertices[i];
  13.         //vec = m_orthographicMatrix * m_viewMatrix * m_modelMatrix * m_cubeVertices[i];
  14.         vec /= vec.w();
  15.         vec = m_screenMatrix * vec;
  16.  
  17.         m_screenPoints[i].x() = vec.x();
  18.         m_screenPoints[i].y() = vec.y();
  19.     }
  20. }

最后,如代码清单 3 所示,把空间变换后的三角形依次进行绘制即可。最终的效果如视频 1 所示,可以看到随着摄像机远离,物体会越来越小。

本章完整代码在 tag/view_transform

代码清单 3 绘制三角形
  1. void TViewTransformRenderTask::Render(TRasterizer& rz)
  2. {
  3.     Transform();
  4.     rz.Clear({ 0,0,0 });
  5.     for (int i = 0; i < 12; i++)
  6.     {
  7.         rz.DrawTriangle(m_screenPoints[3 * i], m_screenPoints[3 * i + 1], m_screenPoints[3 * i + 2],
  8.             { 255, 255, 255 }, { 255, 255, 255 }, { 255, 255, 255 });
  9.     }
  10. }
视频 1 旋转立方体

1. 效果看着有点问题,转动的时候不太平滑,有“咯噔”的感觉。

2. 每帧时间依赖于计算和绘制速度,导致后续物体变小的时候,相机后退得会越来越快。

3. 这边用了纯色,是因为绘制会有叠加错误。应该是目前还没有背面剔除导致。 已解决,见 24.07.01 更新

以上几个问题,看看随着后续学习能否解决。

24.07.01 更新

问题三是没有深度测试造成的。见文章 《深度测试》

说来惭愧,之前学习 OpengGL 使用深度测试,只需要两个函数,真正的“开箱即用”,但没了解它的实际含义。

后续“新接口”的旋转立方体实现:Add rotating cube example for depth testing。如视频 2 所示,效果是正常的。

视频 2 旋转立方体

之前说的“叠加错误”如视频 3 所示,没有开启深度测试。

视频 3 未开启深度测试