变换、坐标系统、摄像机/观察空间

620 阅读11分钟

资料援引

变换

在组合矩阵时,应先进行缩放操作,然后是旋转,最后才是位移。比如,如果先位移再缩放,位移的向量也会同样被缩放(向某方向移动2米,2米也许会被缩放成1米)!

缩放

mat<4, 4, T, Q> scale(mat<4, 4, T, Q> const& m, vec<3, T, Q> const& v);
  • 第一个参数:变换矩阵。
  • 第二个参数:三维向量,依次表示在xyz轴上的缩放倍率。

旋转

mat<4, 4, T, Q> rotate(mat<4, 4, T, Q> const& m, T angle, vec<3, T, Q> const& v)
  • 第一个参数:变换矩阵。
  • 第二个参数:弧度制旋转角度。
  • 第三个参数:三维向量,为 1.0 则表示在对应的xyz轴上进行旋转。

位移

mat<4, 4, T, Q> translate(mat<4, 4, T, Q> const& m, vec<3, T, Q> const& v)
  • 第一个参数:变换矩阵。
  • 第二个参数:三维向量,依次表示在xyz位移的长度。

再遇uniform

// 将矩阵数据发送给顶点着色器中的uniform
glUniformMatrix4fv(GLint location, GLsizei count, GLboolean transpose, const GLfloat *value);
  • 第一个参数:uniform的位置值。
  • 第二个参数:将要发送多少个变换矩阵。
  • 第三个参数:表示是否希望对矩阵进行转置(Transpose),也就是交换矩阵的行和列。OpenGL使用一种内部矩阵布局,叫做列主序布局。GLM的默认布局就是列主序,所以基本不需要转置矩阵。
  • 最后一个参数:变换矩阵数据,但是GLM中矩阵的储存方式和OpenGL接受的不一致,因此要先用GLM的自带的函数value_ptr来变换矩阵的数据类型。

注意

实际的变换顺序应该与阅读顺序相反。

当矩阵相乘时,在最右边的矩阵是第一个与向量相乘的(这是规定),所以应该从右向左读矩阵乘法。

例子,假设有一个顶点(x, y, z),我们希望将其缩放2倍,然后位移(1, 2, 3)个单位,数学表达如下:

TransScaleVec=[1 00 10 1  0  20 0  1  30 0  0  1].[2 00 00 2  0  00 0  2  00 0  0  1].[xyz1]Trans·Scale·Vec= \begin{bmatrix} 1 & 0 & 0 & 1\\ 0 & 1 & 0 & 2\\ 0 & 0 & 1 & 3\\ 0 & 0 & 0 & 1\\ \end{bmatrix} .\begin{bmatrix} 2 & 0 & 0 & 0\\ 0 & 2 & 0 & 0\\ 0 & 0 & 2 & 0\\ 0 & 0 & 0 & 1\\ \end{bmatrix} .\begin{bmatrix} x\\ y\\ z\\ 1 \end{bmatrix}

代码实现如下:

// 添加 1.0f 将顶点变为齐次坐标,便于与矩阵计算。
glm::vec4 vec(x, y, z, 1.0f);
// 变换矩阵,构建顺序为先位移、再缩放;应用之后的实际效果顺序为先缩放、再位移。
glm::mat4 transform = glm::mat4(1.0f); 
transform = glm::translate(transform, glm::vec3(1.0f, 2.0f, 3.0f));
transform = glm::scale(transform, glm::vec3(2.0f, 2.0f, 2.0f));
// 变换矩阵应用于顶点
vec = transform * vec;

可以看到数学表达式的书写顺序和代码构建变换矩阵的顺序是一致的(先书写/构建位移矩阵、再书写/构建缩放矩阵),但是要明白实际的运算顺序是和书写/构建顺序相反的。这是因为矩阵连续点乘时的计算顺序是右乘优先!

坐标系统

程序员通常会自己设定一个坐标的范围,之后再在顶点着色器中将这些坐标变换为标准化设备坐标(-1.0到1.0之间)。然后将这些标准化设备坐标传入光栅器(Rasterizer),将它们变换为屏幕上的二维坐标或像素。

在上述过程中,顶点会经过这样一个流水线:顶点坐标起始于局部坐标,经 模型矩阵(Model Matrix) 后变为世界坐标,经 观察矩阵(View Matrix) 后变成观察坐标,经 投影矩阵(Projection Matrix) 后变成裁剪坐标,并最后以屏幕坐标的形式结束。即 MVP 变换

为什么要设置这么多过渡坐标系统呢?

是因为有些操作在特定的坐标系统中才有意义且更方便。例如,当需要对物体进行修改的时候,在局部空间中来操作会更说得通;如果要对一个物体做出一个相对于其它物体位置的操作时,在世界坐标系中来做这个才更说得通,等等。

也可以定义一个直接从局部空间变换到裁剪空间的变换矩阵,但那样会失去很多灵活性。

坐标系统概述

  • 局部坐标(3D):模型未和其他模型处于同一空间时的坐标。所有模型可能都以  (0,0,0)(0, 0, 0)为初始位置,然而它们会最终出现在世界的不同位置。
  • 世界坐标(3D):顶点在游戏世界中的绝对位置。通过模型矩阵将物体放置到场景的特定方位。
  • 观察坐标(3D):以摄像机为原点的坐标。通过观察矩阵将世界坐标转化为用户视野前方的坐标。
  • 裁剪/投影坐标(4D):通过投影矩阵变换得到的 4D 坐标。此时坐标范围是 [w,w][-w,w]。硬件在这里进行裁剪(剔除视锥体外的顶点)。
  • NDC/标准化设备坐标 (3D) :将裁剪坐标除以 ww(即透视除法)后的结果。所有维度都被压缩到[1,1][-1,1]的标准立方体内,此时物体产生了“近大远小”的效果。
  • 窗口坐标(2D):通过视口变换 (Viewport Transform) 将 NDC 映射到最终具体的屏幕像素位置(如1920×10801920 \times 1080),最终用于光栅化着色。

裁剪坐标

如果只是图元(Primitive),例如三角形的一部分超出了裁剪体积(Clipping Volume),则OpenGL会重新构建这个三角形为一个或多个三角形让其能够适合这个裁剪范围。

投影矩阵

投影矩阵分为正射投影矩阵(Orthographic Projection Matrix)和透视投影矩阵(Perspective Projection Matrix),由投影矩阵创建的观察箱(Viewing Box)被称为平截头体(Frustum),每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上,两种投影矩阵定义了不同的平截头体。

正射投影

正射投影矩阵定义的平截头箱如下图所示:

创建一个正射投影矩阵需要指定宽、高、近(Near)平面和远(Far)平面。任何出现在近平面之前或远平面之后的坐标都会被裁剪掉。正射平截头体直接将坐标映射为标准化设备坐标,因为每个向量的w分量都为1,这也导致正射投影矩阵在屏幕上产生的物体没有远近之分,即将3D坐标直接映射到2D平面中。因此,正射投影主要用于二维渲染以及一些建筑或工程图,在这些场景中更希望顶点不会被透视所干扰。

// 创建正射投影矩阵,参数分别对应左、右坐标,底部、顶部坐标,近平面和远平面
glm::ortho(0.0f, 800.0f, 0.0f, 600.0f, 0.1f, 100.0f);

透视投影

透视矩阵是一种将三维场景投影到二维屏幕上的数学工具。它将定义好的透视平截头体变换到标准化设备坐标空间([-1, 1] 的立方体),这使得后续的投影和裁剪操作更加简单。透视矩阵通常是一个 4x4 的矩阵,其作用是:

  • 将视锥体的空间变换到立方体的空间:透视矩阵会将透视平截头体变换到标准化设备坐标空间,即一个范围在 [-1, 1] 的立方体。

  • 应用透视分割:透视矩阵会应用透视除法,使得离相机越远的点投影在屏幕上越小,从而模拟人眼的透视效果。

符合现实生活中“近大远小”的事实规律,比如两条线在很远的地方看起来会相交。

透视投影矩阵会将给定的平截头体范围映射到裁剪空间,并且会修改每个顶点坐标的w分量,以达到“近大远小”的效果:

  • 近处的物体:对于靠近视点的物体,w 值较小,因此透视除法后,x’y’ 的值会较大,物体看起来更大。
  • 远处的物体:对于远离视点的物体,w 值较大,因此透视除法后,x’y’ 的值会较小,物体看起来更小。

被变换到裁剪空间的坐标都会在-ww的范围之间(任何大于这个范围的坐标都会被裁剪掉)。

透视除法(Perspective Division)

一旦所有顶点被变换到裁剪空间,则将位置向量的 x,y,zx, y, z 分量分别除以向量的齐次 ww 分量,从而将4D裁剪空间坐标变换为3D标准化设备坐标(NDC)。

这一步会在每一个顶点着色器运行的最后被自动执行。也就是说,透视除法是透视投影的最后一步。但它也是光栅化插值的开端。因为透视除法引入了非线性,导致我们在后续插值颜色、纹理、法线时,必须使用 1/w1/w 进行加权修正,否则会出现严重的视觉拉伸。

out=(x/wy/wz/w)out = \begin{pmatrix} x/w \\ y/w \\ z/w \end{pmatrix}

透视矫正插值 (Perspective Correct Interpolation)

虽然透视投影矩阵成功地把物体画在了正确的位置(近大远小),但在光栅化阶段,如果直接在屏幕空间对颜色、纹理坐标(UV)进行线性插值,会出现纹理畸变

数学原理:

在屏幕空间(Screen Space)中,属性 uu 与屏幕坐标 (x,y)(x, y) 并不成线性关系。真正与屏幕坐标成线性关系的是:

  1. 1/w1/w(深度的倒数)
  2. u/wu/w(属性值除以深度)

修正算法步骤:

  1. 顶点处理:计算每个顶点的 1/wi1/w_i 以及 ui/wiu_i/w_i

  2. 屏幕空间插值:利用屏幕空间的重心坐标 (α,β,γ)(\alpha, \beta, \gamma) 插值得到像素点的 I1/wI_{1/w} 和 Iu/wI_{u/w}

    • I1/w=α(1/w0)+β(1/w1)+γ(1/w2)I_{1/w} = \alpha(1/w_0) + \beta(1/w_1) + \gamma(1/w_2)
    • Iu/w=α(u0/w0)+β(u1/w1)+γ(u2/w2)I_{u/w} = \alpha(u_0/w_0) + \beta(u_1/w_1) + \gamma(u_2/w_2)
  3. 属性还原:在每个像素点,通过除法“抵消” ww 的影响:

upixel=Iu/wI1/wu_{pixel} = \frac{I_{u/w}}{I_{1/w}}

image.png

概念行为目的倘若不做?
透视除法(x,y,z)/w(x,y,z)/w产生近大远小的几何形变物体无论远近看起来都一样大(仿射投影
1/w1/w修正1/w1/wuv/wuv/w进行插值确保纹理/颜色在透视下分布正确纹理崩裂、扭曲,墙面上的砖块线条会弯曲或跳动。

为什么通过调整 w 的值,可以模拟真实世界中的近大远小效果?

透视投影利用齐次坐标的齐次分量 ww  产生远小近大的视觉效果,具体过程如下:

  1. 引入 w 分量:三维点 (x,y,z)(x,y,z) 转换为齐次坐标 (x,y,z,1)(x,y,z,1)
  2. 矩阵编码:透视矩阵的最后一行通常为 (0,0,1,0)(0, 0, -1, 0),变换后得到 w=zw' = -z
  3. 齐次除法:将前三个分量除以 ww′,即:
(xw,yw,zw)\left( \frac{x'}{w'}, \frac{y'}{w'}, \frac{z'}{w'} \right)

由于物体越远,zz 的绝对值越大,对应的 ww' 就越大。分母变大导致 x,yx', y' 结果变小,物体在屏幕上向中心收缩,从而实现了“近大远小”。


为什么“随着 zz 增大(点离相机更远)”,ww′ 就会增大呢?

透视投影矩阵在处理的时候,会把深度信息编码进齐次分量 ww′ 中,这样可以在后续的齐次除法中体现远近关系。具体来说,投影矩阵会对 zz 进行缩放和位移操作,并把结果放到 ww′ 中。

以下是一个具体的例子,用于透视投影的典型矩阵的一部分:

(1000010000AB0010)\begin{pmatrix} 1 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 \\ 0 & 0 & A & B \\ 0 & 0 & -1 & 0 \end{pmatrix}

在这个矩阵中, AA 和 BB 是基于近平面和远平面的值来计算的常数。最后一行的 1-1 确保了 zz 值在经过变换之后影响到 ww′

所以,当一个点 (x,y,z)(x,y,z) 经过投影矩阵变换后得到 (x,y,z,w)(x′,y′,z′,w′),具体的 ww′ 是从 z−z 转换来的。这样就保证了:

  • 当物体离相机越远,即 zz 增大时,变换后的 ww′ 也会增大。
  • 最终进行齐次除法时 (xw,yw,zw)\left( \frac{x'}{w'}, \frac{y'}{w'}, \frac{z'}{w'} \right)  的结果就会缩小,从而在屏幕上看起来更小。

透视平截头体

透视平截头体定义了一个视锥体,而透视矩阵将这个视锥体变换到标准化设备坐标(Normalized Device Coordinates, NDC)空间,以便进行后续的光栅化处理。

透视平截头体是一个截锥体形状的空间区域,它定义了相机的视野。这个平截头体由以下几个参数定义:

  • 近剪裁面 (near plane)  和 远剪裁面 (far plane) :它们分别是距离相机最近和最远的两个平面,只有在这两个平面之间的物体才会被渲染。
  • 视角 (field of view, FOV) :通常是垂直视角,定义了视锥体的开口角,和缩放效果之间存在反比关系。也就是说,当视场角增大时,镜头所能看到的范围会变大,从而出现“缩小”的效果;相反,当视场角减小时,看到的范围会变小,从而产生“放大”的效果。
  • 纵横比 (aspect ratio) :视锥体的宽高比。
  • 左右边界 (left, right)  和 上下边界 (top, bottom) :在近剪裁面上的四个边界。

这些参数共同定义了一个四棱锥体,底部较大,顶部较小:

// 定义透视投影矩阵,第一个参数为视角大小(fov),通常为45;
// 第二个参数为宽高比,由视口的宽除以高
// 第三四参数为近远平面,一般为0.1与100
glm::mat4 proj = glm::perspective(glm::radians(45.0f), (float)width/(float)height, 0.1f, 100.0f);

不要把透视矩阵的 near (近平面)值设置的太大(如10.0f)。

OpenGL会将靠近摄像机的坐标(在0.0f和10.0f之间)都裁剪掉,这会导致一个你在游戏中很熟悉的视觉效果:在太过靠近一个物体的时候你的视线会直接穿过去。

窗口坐标空间 (Window Coordinate Space / Screen Space)

在窗口坐标空间中,坐标通常以像素为单位,原点默认位于窗口的左下角(尽管某些窗口系统可能使用左上角),Y轴指向上方。glViewport() 函数用于定义这个视口的矩形区域,从而确定 NDC 坐标如何映射到最终的像素位置。而在2D渲染中,通常不需要复杂的坐标变换,屏幕位置可以直接使用像素坐标。

3D

应用PVM矩阵的流程

用数学公式表达从顶点(VlocalV_{local} )到裁剪坐标(VclipV_{clip} ),变换需要借助模型矩阵、观察矩阵和投影矩阵,而矩阵乘法的运算顺序是从右向左,因此得到:

Vclip=MprojectionMviewMmodelVlocalV_{clip}=M_{projection}⋅M_{view}⋅M_{model}⋅V_{local}

最后的顶点应该被赋值到顶点着色器中的gl_Position,OpenGL将会自动进行透视除法和裁剪。之后会进行视口变换,即OpenGL使用glViewPort内部的参数来将标准化设备坐标映射到屏幕坐标,每个坐标都关联了一个屏幕上的点。

用代码实现上述数学流程来达到3D效果:

  • 定义模型矩阵,将其绕着x轴旋转,使它看起来像把模型放在地上一样。
glm::mat4 modelMat = = glm::mat4(1.0f); // 将矩阵首先初始化为单位矩阵
modelMat = glm::rotate(modelMat, glm::radians(-55.0f), glm::vec3(1.0f, 0, 0));
  • 定义观察矩阵,观察矩阵移动的是整个场景而不是摄像机。由于OpenGL是一个右手坐标系,因此想要摄像机往后移动(即沿着z轴的正方向移动),则需要将场景沿着z轴负方向平移来实现。
glm::mat4 viewMat = = glm::mat4(1.0f); // 将矩阵首先初始化为单位矩阵
// 注意,将矩阵向要进行移动场景的反方向移动。
viewMat = glm::translate(viewMat, glm::vec3(0, 0, -3.0f));
  • 投影矩阵选择透视投影矩阵。
glm::mat4 projMat = = glm::mat4(1.0f); // 将矩阵首先初始化为单位矩阵
projMat = glm::perspective(glm::radians(45.0f),  <screenWidth> / <screenHeight>, 0.1f, 100.0f);
  • 在顶点着色器中运用变换,按照右乘的顺序:
#version 330 core
layout (location = 0) in vec3 aPos;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main(){
    // 注意乘法要从右向左读
    gl_Position = projection * view * model * vec4(aPos, 1.0);
}
  • 在每次的渲染迭代中(因为变换矩阵会经常变动)将矩阵传入着色器中的uniform变量:
int modelLoc = glGetUniformLocation(ourShader.ID, "model"));
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
... // 观察矩阵和投影矩阵与之类似
  • 运行结果:

绘制一个立方体(深度缓冲)

如果想要绘制一个立方体,那么就需要(6个面 x 每个面有2个三角形组成 x 每个三角形有3个顶点)=36个顶点

  • 由于顶点数据中没有颜色数据,注意更改属性读取方式。
/** 不再使用 EBO **/
// position attribute
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
/** 不再有颜色数据 **/
// texture coord attribute
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);
  • 由于没有定义顶点索引,无法使用EBO的glDrawElement()绘画,因此改用:
glDrawArrays(GL_TRIANGLES, 0, 36);

Z缓冲(深度缓冲): GLFW会自动生成深度缓冲(就像它也有一个颜色缓冲来存储输出图像的颜色)。深度值存储在每个片段里面(作为片段的z值)。

深度测试: 当片段想要输出它的颜色时,OpenGL会将它的深度值和z缓冲进行比较,如果当前的片段在其它片段之后,它将会被丢弃,否则将会覆盖已有片段。

  • 深度测试默认是关闭的。因此需要通过glEnable函数来开启深度测试:
// glEnable 用来开启某项 OpenGL 功能
glEnable(GL_DEPTH_TEST);
// 如果想关闭深度测试:
glDisable(GL_DEPTH_TEST);
  • 开启了深度测试后,需要在每次渲染迭代之前清除深度缓冲(否则前一帧的深度信息仍然保存在缓冲中)。
// 就像清除颜色缓冲一样,可以通过在 glClear 函数中指定 DEPTH_BUFFER_BIT 位来清除深度缓冲:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

更多立方体

在实际应用中,很多物体都用了相同的模型,区别在于它们在世界的位置及旋转角度不同。所以当渲染更多物体的时候,不需要改变缓冲数组和属性数组,唯一需要做的只是改变每个对象的模型矩阵来将相同物体变换到世界坐标系中。

  • 首先,为每个立方体定义一个位移向量来指定它在世界空间的位置。我们将在一个glm::vec3数组中定义10个立方体位置:
// 10个立方体的位移向量
glm::vec3 cubePositions[] = {
    glm::vec3( 0.0f,  0.0f,  0.0f),
    glm::vec3( 2.0f,  5.0f, -15.0f),
    glm::vec3(-1.5f, -2.2f, -2.5f),
    glm::vec3(-3.8f, -2.0f, -12.3f),
    glm::vec3( 2.4f, -0.4f, -3.5f),
    glm::vec3(-1.7f,  3.0f, -7.5f),
    glm::vec3( 1.3f, -2.0f, -2.5f),
    glm::vec3( 1.5f,  2.0f, -2.5f),
    glm::vec3( 1.5f,  0.2f, -1.5f),
    glm::vec3(-1.3f,  1.0f, -1.5f)
};
  • 在实际应用中,调用 glDrawArrays 10次,但这次在渲染之前每次传入一个不同的模型矩阵到顶点着色器中,用不同的模型矩阵渲染物体10次,并对每个箱子加一点旋转:
glBindVertexArray(VAO);
for(unsigned int i = 0; i < 10; i++)
{
  glm::mat4 model;
  model = glm::translate(model, cubePositions[i]);
  float angle = 20.0f * i; 
  model = glm::rotate(model, glm::radians(angle), glm::vec3(1.0f, 0.3f, 0.5f));
  ourShader.setMat4("model", model);

  glDrawArrays(GL_TRIANGLES, 0, 36);
}
  • 运行结果:

摄像机/观察空间

创建基于摄像机本身的坐标系

OpenGL本身没有摄像机(Camera)的概念,但可以通过把场景中的所有物体往相反方向移动的方式来模拟出摄像机,产生一种摄像机(视角) 在移动的感觉,而不是场景在移动。

  • 定义摄像机的第一步就是建立一个坐标系(下图中摄像机上的坐标系):

  1. 假设摄像机的位置是:
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f);
  1. 方向向量:摄像机指向场景原点(0, 0, 0),因此称场景原点为目标位置。

目标位置 - 摄像机位置 = 方向同z负轴的指向向量

交换相减的顺序,将该向量置正,就得到的了上图中蓝色向量,又称方向向量。

【方向向量与指向向量(摄像机实际指向)是相反的】:

glm::vec3 cameraTarget = glm::vec3(0.0f, 0.0f, 0.0f);
glm::vec3 cameraDirection = glm::normalize(cameraPos - cameraTarget);

3. 右向量世界坐标上向量和上一步得到的方向向量叉乘得到右向量,右向量与x正半轴方向一致。如果交换两个向量叉乘的顺序就会得到相反的、指向x轴负方向的向量:

glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f); 
// normalize : 向量的单位化(保持其方向不变,将其长度化为1)
// cross : 叉乘
glm::vec3 cameraRight = glm::normalize(glm::cross(up, cameraDirection));
  1. 上向量:将方向向量和右向量进行叉乘:
glm::vec3 cameraUp = glm::cross(cameraDirection, cameraRight);

上述过程在线性代数中叫做格拉姆—施密特正交化(Gram-Schmidt Process)。

上向量并不等价于世界坐标上向量,不论是上向量还是世界坐标上向量,和方向向量叉乘得到的结果都是右向量,但是方向向量和右向量的叉乘结果只会是上向量(而不会是世界坐标上向量)。

或者说,基于摄像机本身生成的坐标系,除了x轴与世界坐标x轴一致,z轴和y轴仅是大致方向一致,但有角度偏差。但为了便于理解,下文以“方向同z负轴的指向向量”表示方向与z负半轴大致一致。

Look At 矩阵

可以通过一个坐标系和一个平移向量来创建一个矩阵,并且可以用这个矩阵乘以任何向量来将其变换到坐标系中。

这个矩阵就是Look At矩阵:

LookAt=[RxRyRz0UxUyUz0DxDyDz00001][100Px010Py001Pz0001]LookAt = \begin{bmatrix} \color{red}{R_x} & \color{red}{R_y} & \color{red}{R_z} & 0 \\ \color{green}{U_x} & \color{green}{U_y} & \color{green}{U_z} & 0 \\ \color{blue}{D_x} & \color{blue}{D_y} & \color{blue}{D_z} & 0 \\ 0 & 0 & 0 & 1 \end{bmatrix} * \begin{bmatrix} 1 & 0 & 0 & -\color{purple}{P_x} \\ 0 & 1 & 0 & -\color{purple}{P_y} \\ 0 & 0 & 1 & -\color{purple}{P_z} \\ 0 & 0 & 0 & 1 \end{bmatrix}

其中R是右向量,U是上向量,D是方向向量,P是摄像机位置向量。注意,位置向量是相反的。把LookAt矩阵作为观察矩阵可以高效地把所有世界坐标变换到刚刚定义的观察空间。

本来我们需要根据上面四步来创建一个Look At矩阵,但幸运的是GLM已经提供了支持。只需要定义一个摄像机位置,一个目标位置和一个世界坐标上向量。接着GLM就会创建一个LookAt矩阵:

glm::mat4 view;
view = glm::lookAt(glm::vec3(0.0f, 0.0f, 3.0f), 
           glm::vec3(0.0f, 0.0f, 0.0f), 
           glm::vec3(0.0f, 1.0f, 0.0f)); // 世界坐标上向量

来尝试模拟一下摄像机绕一个半径为10的圆转圈:

float radius = 10.0f;
float camX = sin(glfwGetTime()) * radius;
float camZ = cos(glfwGetTime()) * radius;
glm::mat4 view;
view = glm::lookAt(glm::vec3(camX, 0.0, camZ), glm::vec3(0.0, 0.0, 0.0), glm::vec3(0.0, 1.0, 0.0)); 

效果参照

建立摄像机系统

事先定义几个重要的向量

glm::vec3 cameraPos   = glm::vec3(0.0f, 0.0f,  3.0f); // 位置
glm::vec3 cameraFront = glm::vec3(0.0f, 0.0f, -1.0f); // 方向同z负轴的指向向量
glm::vec3 cameraUp    = glm::vec3(0.0f, 1.0f,  0.0f); // 上向量

LookAt矩阵:

view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);
// 目标(原点)位置 = 摄像机位置 + 方向同z负轴的指向向量

通过按键更新摄像机位置(实际是更新场景位置):

void processInput(GLFWwindow *window){
    ...
    float cameraSpeed = 0.05f; // adjust accordingly
    // 如果希望向前/向后移动,就把位置向量加上/减去负方向向量。
    if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS)
        cameraPos += cameraSpeed * cameraFront;
    if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS)
        cameraPos -= cameraSpeed * cameraFront;
    // 如果希望向左/右移动,使用叉乘来创建一个右向量(Right Vector),并加上/减去右向量。
    if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS)
        cameraPos -= glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
    if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)
        cameraPos += glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
}

上面对右向量进行了标准化。如果没有标准化,最后的叉乘结果会根据cameraFront变量返回大小不同的向量,此时会根据摄像机的朝向不同加速或减速移动,但如果进行了标准化移动就是匀速的。

移动速度

实际情况下,根据处理器的能力不同,有些设备每秒可以绘制更多帧,也就是以更高的频率调用processInput函数。结果就是,有些设备上移动很快,有些设备上移动很慢。

图形程序和游戏通常会跟踪一个时间差变量,它储存了渲染上一帧所用的时间。如果将速度乘以deltaTime值。结果就是,如果deltaTime很大(上一帧的渲染花费了更多时间),那么同样的速度乘以deltaTime值后也会很大(也就是将这一帧的速度变得更高来平衡渲染所花去的时间)。换言之,渲染花费时间长的移动速度快,反之亦然。使用这种方法时,无论电脑快还是慢,摄像机的速度都会相应平衡。

  • 设置两个全局变量:
float deltaTime = 0.0f; // 当前帧与上一帧的时间差
float lastFrame = 0.0f; // 上一帧的时间
  • 在每一帧中计算出新的deltaTime:
float currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;
  • 计算速度时结合deltaTime:
void processInput(GLFWwindow *window){
  float cameraSpeed = 2.5f * deltaTime;
  ...
}

视角移动/欧拉角

  • 用欧拉角对场景进行旋转,欧拉角是表示3D空间中任何旋转的3个值,一共有3种欧拉角:俯仰角(Pitch)、偏航角(Yaw)和滚转角(Roll)

  • 在摄像机坐标系变换中,假设滚转角是不变的。通过俯仰角和偏航角,计算摄像机的方向向量,即Z轴方向Front,其三个分量的计算方式如下:
  1. 细致来讲,首先根据pitch可以得到:

// len1表示摄像机位置到原点之间的距离
y = sin(glm::radians(pitch)) * len1; // 注意把角度转为弧度
x = cos(glm::radians(pitch)) * len1;
z = cos(glm::radians(pitch)) * len1;
// 把角度转弧度是因为在计算三角函数(如正弦函数 `sin`)时,角度必须以弧度为单位。
// 这是因为大多数编程语言和数学库(包括GLM库)中的三角函数都期望输入值为弧度,而不是度数。
  1. 根据yaw得到:

// len2 其实就是 cos(glm::radians(pitch)) * len1
// 即将摄像机位置到原点之间的距离映射到xz平面上的结果
x = cos(glm::radians(yaw)) * len2; 
z = sin(glm::radians(yaw)) * len2;
  1. 结合pitchyaw

首先汇总一下方向向量 Front 各个分量的计算方法:

// direction代表摄像机的前轴(Front),这个前轴和z的负半轴方向大致一致
direction.x = cos(glm::radians(pitch)) * cos(glm::radians(yaw)) * len1; 
direction.y = sin(glm::radians(pitch)) * len1;
direction.z = cos(glm::radians(pitch)) * sin(glm::radians(yaw)) * len1;

计算公式

  1. Front.x
Front.x = cos(Pitch) × cos(Yaw)Front.x = \cos(Pitch) \times \cos(Yaw)
direction.x = cos(glm::radians(pitch)) * cos(glm::radians(yaw)) * len1; 

在XZ平面上,X分量是由Pitch和Yaw共同决定的。首先,cos(Pitch) 计算出在XZ平面上的投影长度,然后乘以 cos(Yaw) 得到X分量。

  1. Front.y
Front.y = sin(Pitch)Front.y = \sin(Pitch)
direction.y = sin(glm::radians(pitch)) * len1;

Y分量直接由Pitch决定。sin(Pitch) 计算出在Y轴上的投影长度。

  1. Front.z
Front.z = cos(Pitch) ×sin(Yaw)Front.z = \cos(Pitch) \times \sin(Yaw)
direction.z = cos(glm::radians(pitch)) * sin(glm::radians(yaw)) * len1;

在XZ平面上,Z分量是由Pitch和Yaw共同决定的。首先,cos(Pitch) 计算出在XZ平面上的投影长度,然后乘以 sin(Yaw) 得到Z分量。

鼠标输入欧拉角

欧拉角通过鼠标(或手柄)移动获得的,水平的移动影响偏航角,竖直的移动影响俯仰角。它的原理就是,储存上一帧鼠标的位置,在当前帧中计算鼠标位置与上一帧的位置相差多少。如果水平/竖直差别越大那么俯仰角或偏航角就改变越大,也就是摄像机需要移动更多的距离。

  • 首先设置GLFW隐藏并捕捉(Capture)光标:
// GLFW_CURSOR_DISABLED 隐藏并锁定(捕捉)光标,使其不能离开窗口
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
  • 监听鼠标移动事件,并用一个回调函数改变获得鼠标的偏移量,通过偏移量改变欧拉角:
// window:接受到鼠标光标消息的窗口句柄
// x、y:鼠标光标相对于窗口左上角的新位置
void mouse_callback(GLFWwindow* window, double xpos, double ypos){
    if(firstMouse) // 设置初始值 
    {
        lastX = xpos;
        lastY = ypos;
        firstMouse = false;
    }
    /** 为什么要设置初始值? **
    // 在鼠标移动进窗口的那一刻,鼠标回调函数就会被调用,
    // 这时候的xpos和ypos会等于鼠标刚刚进入屏幕的那个位置。
    // 这通常是一个距离屏幕中心很远的地方,因而产生一个很大的偏移量,所以就会跳一下。
    // 因此可以在第一次鼠标进入窗口时强行设置为屏幕中心。

    /** 1.计算鼠标自上一帧的偏移量 **/
    float xoffset = xpos - lastX;
    // 注意这里是相反的,因为y坐标是从底部往顶部依次增大的
    float yoffset = lastY - ypos; 
    // float yoffset = ypos - lastY; 这种写法会影响y轴,鼠标向下图像会向上,反之亦然。
    lastX = xpos;
    lastY = ypos;

    // 灵敏度,用来调节鼠标移动幅度
    float sensitivity = 0.05;
    xoffset *= sensitivity;
    yoffset *= sensitivity;

    /** 2.把偏移量添加到摄像机的俯仰角和偏航角中 **/
    yaw   += xoffset;
    pitch += yoffset;

    /** 3.对偏航角和俯仰角进行最大和最小值的限制 **/
    // 对于俯仰角,要让用户不能看向高于89度的地方
    // 在90度时视角会发生逆转,所以把89度作为极限
    // 同样也不允许小于-89度。这样能够保证用户只能看到天空或脚下
    if(pitch > 89.0f)
        pitch = 89.0f;
    if(pitch < -89.0f)
        pitch = -89.0f;

    /** 4.计算方向向量 **/
    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);
}
  • 注册回调函数:
glfwSetCursorPosCallback(window, mouse_callback);

使用欧拉角的摄像机系统并不完美,最好的摄像机系统是使用四元数(Quaternions)的(这里可以查看四元数摄像机的实现)。

滚轮缩放

使用鼠标的滚轮来操纵fov的大小,以实现视角缩放:

  • 鼠标滚轮的回调函数:
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset){
  if(fov >= 1.0f && fov <= 45.0f)
    fov -= yoffset; // yoffset值代表竖直滚动的大小
  if(fov <= 1.0f)
    fov = 1.0f;
  if(fov >= 45.0f)
    fov = 45.0f;
}
  • 修改循环渲染函数,每一帧都必须把透视投影矩阵上传到GPU,使用fov变量作为视野:
projection = glm::perspective(glm::radians(fov), 800.0f / 600.0f, 0.1f, 100.0f);
  • 注册鼠标滚轮的回调函数:
glfwSetScrollCallback(window, scroll_callback);

摄像机类

  • 将摄像机的相关代码抽象出来作为一个类放在单独的头文件中,代码详见
  • 使用摄像机类代替之前的部分代码,代码详见
  • 使摄像机只能在xz平面上移动,y恒为0代码详见