图形学系列文章目录
- 序章 初探图形编程
- 第1章 你的第一个三角形
- 第2章 变换
- 顶点变换
- 视图矩阵 & 帧速率
- 第3章 纹理映射
- 第4章 透明度和深度
- 第5章 裁剪区域和模板缓冲区
- 第6章 场景图
- 第7章 场景管理
- 第8章 索引缓冲区
- 第9章 骨骼动画
- 第10章 后处理
- 第11章 实时光照(一)
- 第12章 实时光照(二)
- 第13章 立方体贴图
- 第14章 阴影贴图
- 第15章 延迟渲染
- 第16章 高级缓冲区
文章目录
- 图形学系列文章目录
- 前言
- 矩阵基础
- 矩阵乘法
- 交换律
- 单位矩阵
- 顶点变换
- 模型矩阵
- 平移
- 旋转
- 缩放
- 投影矩阵
- 近平面和远平面
- 正交投影
- 透视投影
- 透视除法
- 示例程序
- Renderer头文件
- Renderer类文件
- 主程序
- 顶点着色器
- 运行程序
- 总结
- 课后作业
前言
在上一节教程中,你学会了在屏幕上绘制三角形 - 现在你将学习如何使用平移、缩放和旋转矩阵来变换该三角形。你还将学习投影矩阵的知识,它可以给你渲染场景的深度感。
在第一个教程中,我们直接在裁剪空间中渲染了一个三角形。作为入门介绍这没问题,但从长远来看并不是特别有用!在本节课中,你将学习如何使用顶点着色器,将顶点从它们的局部坐标空间,经过模型矩阵和投影矩阵,依次变换到世界空间,最后到裁剪空间。
矩阵基础
模型矩阵、投影矩阵以及之后会介绍的视图矩阵和纹理矩阵都是一种变换矩阵——用于操作齐次坐标的矩阵。齐次坐标有一个额外的坐标,通常设为 1.0——还记得上一个教程中顶点位置在顶点着色器中必须扩展为 4 分量向量吗?这是为了保持它们是齐次的——稍后你会看到这个额外维度的坐标是如何使用的。这些变换矩阵是 4×4 的方阵。通常它们的表示如下:
它包含 16 个实数,排列成 4 行 4 列。一般来说,这些元素会以浮点数的形式存储——图形硬件非常适合对 4 分量向量进行浮点运算,这也使得它们非常适合用于矩阵计算。
矩阵乘法
在你的图形应用程序中,你会经常发现自己将矩阵相乘,无论是在你的 C++ 代码中还是在你的着色器中。这是因为是乘法而不是加法将矩阵的效果串联在一起。例如,在本教程中,你将学习如何创建在空间中平移顶点位置的矩阵以及旋转顶点的矩阵。为了对一个向量同时进行平移和旋转,这两个变换矩阵必须相乘。这是这样进行的,结果矩阵的每个值都是第一个矩阵的相关行与第二个矩阵的列的点积:
交换律
矩阵相乘的方法意味着乘法顺序不满足交换律——它们的顺序很重要!例如,矩阵 a 乘以矩阵 b 与矩阵 b 乘以矩阵 a 是不一样的。
单位矩阵
在处理变换矩阵时,可能需要一个不做任何事情的矩阵——也许你想在原点处直接绘制某个东西而不做任何变换?为此,会使用单位矩阵。它看起来像这样:
将任何矩阵与单位矩阵相乘,结果将是一个与完全相同的矩阵。值得指出的是,在 OpenGL 中,如果仅使用单位矩阵进行变换,你的视点将沿着负 z 轴方向看——所以要向前移动,实际上你需要进行“减法”操作!
顶点变换
在顶点着色器中,通过将顶点的位置向量与由模型矩阵、视图矩阵和投影矩阵相乘得到的矩阵相乘,将顶点变换到最终的裁剪空间位置。向量与矩阵相乘的方式如下(记住,我们通过添加一个 1 使我们的 3 分量顶点位置变为齐次坐标!):
模型矩阵
顶点着色器使用模型矩阵将传入的顶点从局部空间(在创建网格时在数组中定义的坐标值或从文件中加载的坐标值)变换到世界空间(确定场景中物体相互位置关系的全局坐标系)。一个网格的所有顶点都将由同一个模型矩阵进行变换,并且可以包含将物体变换到世界空间所需的平移、旋转、缩放和错切信息的任意组合。通过将变换矩阵相乘,可以创建一个模型矩阵,用于将物体平移、旋转和缩放到世界空间中的任何位置。
平移
一个平移矩阵具有以下特性。它的对角线上的值为 1.0,并且在右侧有一个平移分量——这是顶点在每个轴上平移的量:
例如,我们可以使用以下矩阵将局部空间中一个网格的原点(0,0,0)平移到世界空间位置(10,10,10):
旋转
旋转矩阵围绕一个轴旋转一个向量——这个轴被定义为一个归一化的三分量向量,其分量为 x、y、z。它的定义如下:
其中 c 是旋转向量的角度的余弦值,s 是正弦值。
举个例子:想象我们有一条简单的线,从原点开始,沿着 x 轴延伸 10 个单位。如果我们想把这条线旋转,使其指向 z 轴,我们需要绕 y 轴(0,1,0)将这条线的终点旋转 -90°——可以把旋转轴想象成一个穿过物体的轴,物体围绕它旋转。
其中 c = cos(-90)=0,s = sin(-90)= -1。
缩放
将一个向量与一个缩放矩阵相乘会在每个轴的基础上缩放其值——这意味着结果会更靠近或更远离原点。它的定义如下:
其中 x、y 和 z 是每个轴的缩放因子——它们可以是负数!接着前面的例子,想象现在我们希望我们那条 10 个单位长的线变成 100 个单位长。现在这条线指向 z 轴,所以下面这个缩放矩阵将使 10 个单位长的线变成 100 个单位长。
投影矩阵
投影矩阵将我们的世界坐标映射到裁剪空间,裁剪空间将顶点映射到每个轴上从 -w 到 w 的空间,其中 w 是顶点自身的齐次坐标 w 分量。任何超出这些值的图元将被剔除(如果整个图元在这个范围之外)或裁剪(如果图元的一部分仍然可见)。这个矩阵也用于将我们通过变换矩阵和顶点坐标定义的 3D 世界投影到一个平面上——本质上,你可以把这个平面想象成我们的 2D 显示器屏幕。作为这个投影过程的一部分,可以为场景添加透视感——模型矩阵和相机矩阵没有真正的数学运算能使远离相机的物体变小。有多种方法可以计算投影矩阵的值,但它们可以分为两种基本类型,即正交投影和透视投影。
近平面和远平面
我们在一个浮点值中只有这么多精度,所以我们不能真正有一个真正延伸到无穷远的视图。相反,我们必须通过使用近平面和远平面来限制屏幕上看到的内容——近平面前面的任何东西都不会被绘制,远平面后面的任何东西也都不会被绘制。这两个平面之间的空间越大,我们在场景中可使用的精度位数就越少,所以最好将近平面和远平面限制在只需要的大小。从技术上讲,投影矩阵定义了六个这样所谓的“裁剪”平面,因为对于我们场景的左、右、上和下也各有一个平面。这些平面一起被称为视锥体——在本模块的后面你将更多地处理视锥体。
三角形 a 在远平面的后面,所以不会被看到,而三角形 c 实际上与远平面相交——三角形 c 的部分将被剔除!
正交投影
两种投影矩阵类型中较简单的是正交矩阵。与直接绘制到裁剪空间一样,正交投影完全是平行的——场景中没有添加透视。然而,它确实允许使用一些值来确定每个轴的最大可视范围,从而在原点周围创建一个长方体的可视区域。然后,这些最大可视范围通过矩阵被“压缩”到从 -1 到 1——正交矩阵不会改变顶点的 w 分量,所以得到的裁剪空间从 -1 到 1。最常见的正交投影矩阵定义如下:
所以,与我们在上一个教程中直接进入裁剪空间时每个轴的可视空间从 -1 到 1 不同,在使用正交矩阵时我们有几个选择。我们可以通过使用以下值来创建一个在每个轴上从 -100 到 100 的可视区域:
你应该能够看到使用以下值如何形成一个单位矩阵,从而使裁剪空间保持为结果坐标空间:
或者我们可以使用以下值创建一个投影矩阵,该矩阵创建一个与视图区域匹配的空间:
这个特别有用,因为它允许在屏幕上精确地放置物体——空间中的每个单位等于屏幕上的一个像素。它通常用于在游戏中绘制 HUD、菜单和文本,可以直接使用屏幕宽度和高度,或者使用“虚拟画布”——id Software 的《毁灭战士 3》中使用的正交投影始终使用 640 的宽度和 480 的高度,无论实际屏幕分辨率是多少。这使得艺术家能够以与屏幕分辨率无关的方式准确地确定屏幕上血条等的位置。
透视投影
在像第一人称射击游戏这样的 3D 游戏中,通常使用透视投影。正是这种添加的透视缩短效果使得物体在靠近和远离视点时变大和变小,并且使得延伸到远处的平行线在它们的消失点处看起来汇聚。透视投影通常定义如下:
其中,,和分别是近平面和远平面,是垂直视野——即视角应该有多宽,以度为单位。这个值越大,视图法线侧面的物体就越可见。曾经第一人称射击游戏通常有 45°的视野,但现在许多现代主机第一人称射击游戏的垂直视野低至 30°。
透视除法
你会注意到透视投影矩阵在处的值为 -1。如果你按照向量与矩阵相乘的数学运算,你会看到这会将输入向量的负 z 值放入输出向量的 w 值中。由于透视除法,这变得很重要。在顶点着色器完成后,输出向量的 x、y 和 z 分量被 w 分量除,将“裁剪空间”坐标转换为它们最终的“标准化设备坐标”。正是这种除法使得远处的物体看起来更小——物体离得越远,w 分量就越大,导致除法后的 x、y 和 z 值越小,使得所有物体离得越远就越靠近屏幕中心。你还会注意到我们在矩阵的 z 轴上有一个平移——这是由于透视除法的一个副作用。如果我们将 z 轴除以 w(实际上 w 就是 z 轴的值),那么在透视除法后 z 将始终等于 1。这很糟糕,因为这意味着我们失去了确定一个三角形是否在另一个三角形后面的能力。所以,我们添加一个从近平面和远平面推导出来的平移,这样即使我们平移后的 z 已经被“未平移”的 w 值除了,我们仍然最终得到一个唯一的值来表示一个顶点在场景中的“深度”,并且仍然将 z 轴从 -1 映射到 1。
为了展示透视除法的效果,这里是对一些表示顶点位置的向量进行透视除法的结果。如果我们用一个近平面值为 1、远平面值为 100、垂直视野为 45.0 以及纵横比为 1.33(例如,从 800×600 的屏幕分辨率得出)来形成一个透视矩阵,我们将得到以下矩阵:
将在原点右侧 10 个单位且与视点距离不同的向量与这个透视矩阵相乘,我们得到以下结果:
在透视除法步骤之后,我们将得到以下向量:
如果你看这四个透视除法后的向量,你会看到它们的 x 轴位置随着距离的增加而趋向于 0——这就是使得远处的几何形状变小的原因,也是在平行线上产生“消失点”效果的原因。你还应该看到 z 轴也受到了影响——透视除法的效果是非线性的,所以向量 A’和 B’之间的 z 轴差异远大于 C’和 D’之间的差异,这在后面查看深度缓冲区时会变得很重要。
示例程序
本教程的示例程序将允许我们以透视或正交模式渲染三个三角形。然后可以使用模型矩阵对这些三角形进行平移、旋转和缩放,通过键盘控制。早期版本的 OpenGL 通过其矩阵栈为投影矩阵和模型矩阵的操作提供了内置支持,但现代 OpenGL 摒弃了这一点,迫使你自己处理所有基于矩阵的功能。相反,我们将使用 Matrix4类来处理我们的矩阵需求。由于矩阵对于图形场景的正确渲染是如此重要,本教程所继承的 OGLRenderer 类具有模型矩阵和投影矩阵作为成员变量,还有一个视图矩阵,你将在下一个教程中使用它。
在你的 Tutorial2 解决方案中,创建一个从 OGLRenderer 继承的 Renderer 类,以及一个名为 Tutorial2.cpp 的文本文件。我们将重复使用上一个教程的片段着色器,但编写一个新的顶点着色器,所以在“…/Shaders/”文件夹中创建一个名为 MatrixVertex.glsl 的文本文件。
Renderer头文件
本教程中的渲染器类与教程 1 中的非常相似。这次,我们有三个新的protected
成员变量——这些将控制我们渲染的三角形的缩放、旋转和平移。我们还为每个变量提供了公共访问器,这些将在我们的主循环中使用——由于它们非常简单,我们将在头文件中定义它们。最后,还有两个额外的公共函数,将用于在透视投影和正交投影之间切换。
//Renderer.h
#pragma once
#include "../NCLGL/OGLRenderer.h"
class Renderer : public OGLRenderer
{
public:
Renderer(Window& parent);
virtual ~Renderer(void);
virtual void RenderScene();
void SwitchToPerspective();
void SwitchToOrthographic();
inline void SetScale(float s) { scale = s; }
inline void SetRotation(float r) { rotation = r; }
inline void SetPosition(Vector3 p) { position = p; }
protected:
Mesh* triangle;
Shader* matrixShader;
float scale;
float rotation;
Vector3 position;
};
Renderer类文件
本教程中渲染器类的构造函数和析构函数与教程 1 类似——但请注意第 6 行的新顶点着色器,以及第 13 行对我们的 SwitchToOrthographic 函数的调用。
//Renderer.cpp
#include "Renderer.h"
Renderer::Renderer(Window& parent) : OGLRenderer(parent) {
triangle = Mesh::GenerateTriangle();
matrixShader = new Shader("MatrixVertex.glsl", "colourFragment.glsl");
camera = new Camera();
if (!matrixShader->LoadSuccess())
{
return;
}
init = true;
SwitchToOrthographic();
}
Renderer ::~Renderer(void) {
delete triangle;
delete matrixShader;
}
本教程中的接下来两个函数在透视投影和正交投影之间切换投影矩阵,使用Matrix4 类函数 Perspective 和 Orthographic,这些函数只是创建前面描述的矩阵。Perspective 接受四个参数——一个近平面和远平面的 z 值、一个纵横比(记得在透视矩阵的形成中 aspect 是如何使用的吗?)以及一个水平视野。
void Renderer::SwitchToPerspective()
{
projMatrix = Matrix4::Perspective(1.0f, 10000.0f, (float)width / (float)height, 45.0f);
}
Orthographic 接受六个参数——每个轴和方向各一个,顺序是后、前、右、左、上、下。
void Renderer::SwitchToOrthographic()
{
projMatrix = Matrix4::Orthographic(-1.0f, 10000.0f, width / 2.0f, -width / 2.0f, height / 2.0f, -height / 2.0f);
}
两种矩阵投影的远平面值都是 10000——足以应对一个大型场景。透视投影的近平面值是 1.0——这是一个相当常见的默认值。由于深度缓冲区的值是这样计算的,近平面值越接近 0,深度缓冲区的精度就越低。然而,正交投影的近平面是 -1。为什么呢?正交投影经常用于在屏幕上绘制文本和 HUD 信息,对于这些元素来说,深度为 0 是很直观的(通过从不将其从默认位置移开)。所以,为了确保绘制这些项目,正交投影通常使用负的近平面值。
最后,我们有本教程渲染器的 RenderScene 函数。第 6 行和第 7 行展示了如何更新着色器的矩阵统一变量。glUniformMatrix4fv 函数调用有点冗长——第一个参数是变量名,第二个参数是要更新的矩阵数量(可以有一个矩阵数组!),第三个参数是矩阵是否应该转置,第四个参数是指向矩阵数据的指针。从第 42 行开始,我们在世界中渲染三个三角形,它们离原点越来越远。第 14 行展示了如何通过将几个变换矩阵相乘来形成一个对象的模型矩阵,在这种情况下,是平移、旋转和缩放,所有这些都由渲染器类的局部变量控制。第 15 行然后将这个连接的模型矩阵发送给着色器,第 16 行在由模型矩阵确定的位置绘制当前三角形。
void Renderer::RenderScene() {
glClear(GL_COLOR_BUFFER_BIT);
BindShader(matrixShader);
//UpdateShaderMatrices();
glUniformMatrix4fv(glGetUniformLocation(matrixShader->GetProgram(), "projMatrix"), 1, false, projMatrix.values);
glUniformMatrix4fv(glGetUniformLocation(matrixShader->GetProgram(), "viewMatrix"), 1, false, viewMatrix.values);
for (int i = 0; i < 3; ++i)
{
Vector3 tempPos = position;
tempPos.z += (i * 500.0f);
tempPos.x -= (i * 100.0f);
tempPos.y -= (i * 100.0f);
modelMatrix = Matrix4::Translation(tempPos)*Matrix4::Rotation(rotation, Vector3(0, 1, 0))* Matrix4::Scale(Vector3(scale, scale, scale));
glUniformMatrix4fv(glGetUniformLocation(matrixShader->GetProgram(), "modelMatrix"), 1, false, modelMatrix.values);
triangle->Draw();
}
}
主程序
这次我们的main
函数相当长,但仍然非常简单!我们希望能够旋转、缩放和平移我们的三角形,所以我们需要对每个操作进行按键检查。“+”和“-”键控制缩放局部变量,“I”、“J”、“K”、“L”、“O”和“P”键控制位置局部变量,最后左右箭头键控制旋转局部变量。然后,这些通过我们之前声明的访问器函数在每一帧发送给渲染器,最后调用 RenderScene 函数。注意位置的 z 轴是如何设置为 -1500.0f 的——我们绘制三个三角形,每个三角形比前一个三角形近 500 个单位,所以最后一个三角形的 z 轴位置将是 0.0f。
#include "../nclgl/window.h"
#include "Renderer.h"
int main() {
Window w("Vertex Transformation!", 800, 600, false);
if (!w.HasInitialised()) {
return -1;
}
Renderer renderer(w);
if (!renderer.HasInitialised()) {
return -1;
}
float scale = 100.0f;
float rotation = 0.0f;
Vector3 position(0, 0, -1500.0f);
while (w.UpdateWindow() && !Window::GetKeyboard()->KeyDown(KEYBOARD_ESCAPE)) {
if (Window::GetKeyboard()->KeyDown(KEYBOARD_1))
renderer.SwitchToOrthographic();
if (Window::GetKeyboard()->KeyDown(KEYBOARD_2))
renderer.SwitchToPerspective();
if (Window::GetKeyboard()->KeyDown(KEYBOARD_PLUS)) ++scale;
if (Window::GetKeyboard()->KeyDown(KEYBOARD_MINUS)) --scale;
if (Window::GetKeyboard()->KeyDown(KEYBOARD_LEFT)) ++rotation;
if (Window::GetKeyboard()->KeyDown(KEYBOARD_RIGHT)) --rotation;
if (Window::GetKeyboard()->KeyDown(KEYBOARD_K))
position.y -= 1.0f;
if (Window::GetKeyboard()->KeyDown(KEYBOARD_I))
position.y += 1.0f;
if (Window::GetKeyboard()->KeyDown(KEYBOARD_J))
position.x -= 1.0f;
if (Window::GetKeyboard()->KeyDown(KEYBOARD_L))
position.x += 1.0f;
if (Window::GetKeyboard()->KeyDown(KEYBOARD_O))
position.z -= 1.0f;
if (Window::GetKeyboard()->KeyDown(KEYBOARD_P))
position.z += 1.0f;
renderer.SetRotation(rotation);
renderer.SetScale(scale);
renderer.SetPosition(position);
renderer.RenderScene();
renderer.SwapBuffers();
}
return 0;
}
顶点着色器
我们的顶点着色器与上一个教程类似,但有三个新的统一变量,分别对应我们的三个矩阵。为了将传入的顶点变换到正确的位置,我们必须将它们与一个组合的“模型视图投影”矩阵相乘。记住,矩阵乘法不满足交换律,所以我们将矩阵相乘的顺序很重要——在这种情况下,我们以相反的顺序相乘来创建正确的矩阵。viewMatrix 变量将被设置为单位矩阵,因此不会改变 mvp 变量。在下一个教程中,你将看到如何使用这个变量将顶点变换到相对于相机视点的局部空间中。
# version 330 core
uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projMatrix;
in vec3 position ;
in vec4 colour ;
out Vertex {
vec4 colour ;
} OUT;
void main ( void ) {
mat4 mvp = projMatrix * viewMatrix * modelMatrix ;
gl_Position = mvp * vec4 ( position , 1.0);
OUT . colour = colour ;
}
运行程序
当运行程序时,你应该看到一个由三个三角形组成的场景,处于正交投影中。你会注意到,即使我们的 RenderScene 中的 for 循环使每个三角形比前一个三角形靠近“相机”500.0f 个单位,但每个三角形的大小是相同的。与按下数字键“2”时看到的场景的透视投影形成对比,在透视投影中,较远的三角形会变小。我们还可以使用“I”、“J”、“K”和“L”键平移我们的三角形。再次注意,正交投影和透视投影有不同的结果——无论我们将三角形从屏幕移开多远,它们的大小保持不变,但在透视投影中,它们会越来越小——在这两种情况下,它们最终都会消失,因为它们到达了远平面的 z 距离。
总结
完成本教程后,你应该知道如何通过在世界坐标中平移、旋转和缩放来变换你的网格。你现在也应该知道矩阵相乘的顺序很重要。最后,你应该了解如何将投影矩阵应用到你的场景中。正交投影对于 2D 游戏以及绘制游戏中的 HUD 很有用,而透视投影对于 3D 游戏更有用。正如演示程序所示,在单个应用程序中同时使用正交投影和透视投影很容易,所以你应该开始看到游戏如何使用这两种投影来渲染它们的场景。下一个教程,我们将看看第三个变换矩阵——视图矩阵。这将允许我们独立于模型矩阵在场景中平移和旋转我们的视点。
课后作业
- 如果你在 RenderScene 函数中改变矩阵相乘的顺序会发生什么?为什么会这样?
- 在绘制游戏中的 HUD 时,能够直接针对屏幕进行绘制是很有用的,这样模型空间的平移就等于屏幕上的像素。什么样的投影矩阵可以实现这一点?
- 一些游戏使用透视矩阵的视野参数来创建视觉效果——想想在第一人称射击游戏中使用瞄准镜时视野的缩小,或者在《异形大战铁血战士》中的鱼眼镜头效果。以与三角形旋转成员变量类似的方式向 Renderer 类添加一个可控制的视野成员变量。