图形学系列专栏

  • 序章 初探图形编程
  • 第1章 你的第一个三角形
  • 第2章 变换
  • 顶点变换
  • 视图矩阵 & 帧速率
  • 第3章 纹理映射
  • 第4章 透明度和深度
  • 第5章 裁剪区域和模板缓冲区
  • 第6章 场景图
  • 第7章 场景管理
  • 第8章 索引缓冲区
  • 第9章 骨骼动画
  • 第10章 后处理
  • 第11章 实时光照(一)
  • 第12章 实时光照(二)
  • 第13章 立方体贴图
  • 第14章 阴影贴图
  • 第15章 延迟渲染
  • 第16章 高级缓冲区


文章目录

  • 图形学系列专栏
  • 前言
  • 视图矩阵
  • 帧率独立性
  • 示例程序
  • Camera头文件
  • Camera类文件
  • 使用这个类
  • 总结
  • 课后作业



图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_c++

前言

现在你已经可以在世界空间中移动你的物体了,你可能也想知道如何移动你的视角。本教程将向你展示如何使用视图矩阵来做到这一点,并展示一个在 OpenGL 应用程序中使用的简单的“相机类”的示例。你还将看到如何以恒定的速度在你的场景中移动,而不管你的帧率是多少。

在上一个教程中,你简单地了解了视图矩阵,它作为 OGLRenderer 类的一个成员变量和着色器的一个统一变量出现,但你并没有对它们做太多操作。在本课程中,你将学习如何操作视图矩阵,从而能够在你的场景中移动。为了演示这一点,我们将创建一个“相机类”,它能响应典型的第一人称射击游戏的鼠标和键盘命令,并且有一个创建有效视图矩阵的函数。然后,我们将把它添加到上一个教程的代码中,以展示向你的 OpenGL 应用程序添加相机支持是多么容易。此外,随着你的场景变得更加复杂,你可能会发现你的应用程序无法保持恒定的帧率,所以本课程还将向你展示如何应对这种情况可能带来的一些副作用。

视图矩阵

和模型矩阵一样,视图矩阵可以包含平移和旋转分量——但它不是变换单个对象,而是变换所有东西!它将你的几何图形从世界空间变换到视图空间——也就是说,一个以你期望的视点为原点的坐标系。早期版本的 OpenGL 将模型矩阵和视图矩阵组合在一起,形成一个“模型视图”矩阵,但 OpenGL 3 和大多数其他图形渲染 API 将它们分开,直到在顶点着色器中相乘在一起。就像你一直在使用模型矩阵来平移和旋转你的网格一样,你可以把视图矩阵看作是视点的模型矩阵——它可以包含上一个教程中介绍的任何变换分量,包括平移和旋转。

然而,有一件重要的事情需要考虑——要形成一个正确的视图矩阵,你执行的矩阵变换必须是反向的。下面的例子应该能清楚地说明为什么。想象一个场景,你的相机在原点,沿着负 z 轴看去,朝着一个距离为 10 个单位的立方体。

图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_矩阵_02


相机矩阵、立方体的模型矩阵以及它们形成的组合“模型视图”矩阵如下所示:

图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_线性代数_03

得到的矩阵将立方体放置在距离相机 10 个单位的位置,正如预期的那样。现在,想象一下立方体和相机都沿着 x 轴向下移动 10 个单位。形成的矩阵将如下所示:

图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_c++_04

这看起来不对!如果我们使用这个矩阵来渲染我们的对象,它会出现在屏幕的右侧,而它应该仍然在中心,因为立方体和相机都沿着同一轴移动了相同的量。

图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_游戏引擎_05


相反,我们使用相机矩阵的逆矩阵,这样它就以相反的方式进行变换:

图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_c++_06

这样好多了!使用逆矩阵给我们一个最终矩阵,将立方体放置在相机前方 10 个单位的距离处。

图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_c++_07

帧率独立性

在你目前编写的应用程序中,你可能会得到一个恒定的、非常高的帧率——确切的帧率将取决于你的图形驱动程序是否将你的帧率锁定为显示器的刷新率。但是,如果你的游戏变得足够复杂,导致帧率变得不一致呢?

想想你在上一个教程中所做的——每一帧,如果按下 J 键,三角形就向左移动 1 个单位。所以,如果你的帧率被限制在每秒 60 帧,你的三角形就会向左移动 60 个单位。但是如果你的帧率不受限制呢?在如此简单的场景中,你可能会得到每秒超过 1000 帧——这意味着你的三角形在一秒内会向左移动 1000 个单位!

最简单的解决方案,也是我们在这些教程中将要使用的方案,是根据自上一帧以来经过的时间来缩放值。时间可以测量的准确程度以及所使用的测量单位将取决于你的操作系统和所使用的函数。提供的代码库带有一个“游戏计时器类”,它提供的函数返回的值等于以毫秒或秒为单位的经过时间,计时精度在不到一毫秒以内。使用这些函数,在不同的帧率下保持移动看起来一致就变得更容易了。假设我们想让一个三角形在一秒内向左移动 60 个单位,我们现在可以这样做:
图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_c++_08
其中 dt 是我们的时间增量,以秒为单位,表示自上一帧以来经过了多少时间。如果我们的帧率只有 1 FPS,我们最终会得到:
图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_图形渲染_09
如果我们的帧率是 30 FPS,我们会得到以下结果(1 秒/30 FPS = 0.0333 秒):
图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_游戏引擎_10
在一秒的时间内应用 30 次,这将使我们得到 60 个单位的移动。
现在,我们可以通过使用这个公式为我们游戏中的所有旋转、平移以及我们能想到的任何其他东西计算与帧率无关的值,从而在可变帧率下保持一切一致!这并不完美——由于浮点值的不准确性导致的舍入误差意味着事情可能会略有偏差,但目前来说已经足够好了。这些不准确性可能会产生意想不到的后果——一个著名的例子是,在《雷神之锤 3》中,某些技巧跳跃只有在玩家的帧率恒定为 120FPS 时才能执行!

示例程序

本教程没有新的程序;相反,我们将创建一个新的类,并在上一个教程中创建的Renderer类中添加一个虚函数。在解决方案中创建一个名为Camera的新类——我们将经常使用“相机类”!现在,我们将在上一个教程的示例程序中添加一个相机,这样我们就可以在其顶点着色器中使用视图矩阵变量。

Camera头文件

我们的“相机类”将使用Matrix4 类和Vector3 类,所以我们必须包含它们的头文件。这个类有三个受保护的成员变量——它在世界空间中的位置,以及它的偏航角和俯仰角。如果你不知道的话,俯仰角是指某物朝上或朝下的角度,而偏航角是它的朝向。我们也为这些变量提供了公共的访问函数——由于代码很简单,我们将把它们合并到头文件中。我们也将用几个构造函数来实现这一点,一个是默认构造函数,另一个是接受参数以显式设置成员变量的构造函数。最后,我们还有两个公共函数,UpdateCameraBuildViewMatrix。你很快就会看到它们的作用。

#pragma once
#include "Matrix4.h"
#include "Vector3.h"
class Camera
{
public:
	Camera(void) {
		yaw = 0.0f;
		pitch = 0.0f;
	};
	Camera(float pitch, float yaw, Vector3 position) {
		this->pitch = pitch;
		this->yaw = yaw;
		this->position = position;
	}
	~Camera(void) {};

	void UpdateCamera(float dt = 1.0f);

	Matrix4 BuildViewMatrix();

	Vector3 GetPosition() const { return position; }
	void SetPosition(Vector3 val) { position = val; }

	float GetYaw() const { return yaw; }
	void SetYaw(float y) { yaw = y; }

	float GetPitch() const { return pitch; }
	void SetPitch(float p) { pitch = p; }
protected:
	float yaw;
	float pitch;
	Vector3 position;
};

Camera类文件

首先是UpdateCamera函数。这个函数将读取鼠标和键盘输入,并相应地更新成员变量。这并不像你一开始想的那么容易,但也不是太难。首先,我们从鼠标的 y 轴(鼠标的上下移动)读取俯仰角(图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_线性代数_11),从 x 轴(鼠标的左右移动)读取偏航角(图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_c++_12)——Mouse类的GetRelativePosition函数返回自上一游戏帧以来鼠标移动了多少。然后,我们将俯仰角(图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_线性代数_11)变量锁定在 90 度到 -90 度之间——就像在第一人称射击游戏中一样。我们还对偏航角(图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_c++_12)变量进行了一些合理性检查,以使其保持在 0 到 360 度的范围内。

#include "Camera.h"
#include "Window.h"
#include <algorithm>
using namespace std;
void Camera::UpdateCamera(float dt)
{
	pitch -= (Window::GetMouse()->GetRelativePosition().y);
	yaw -= (Window::GetMouse()->GetRelativePosition().x);

	pitch = min(pitch, 90.0f);
	pitch = max(pitch, -90.0f);
	
	if (yaw < 0) {
		yaw += 360.0f;
	}
	if (yaw > 360.0f) {
		yaw -= 360.0f;
	}

为了在游戏世界中移动相机,我们将使用常见的 WASD 键盘输入来控制 x 轴和 z 轴。为此,我们将使用偏航角变量形成一个旋转矩阵,并使用它来乘以一个指向负 z 轴方向的向量——将其旋转以指向相机所面对的方向(在 OpenGL 中,forward通常被认为是沿着负 z 轴方向)。我们还将通过用相同的旋转矩阵旋转一个指向右边的向量来计算出一个sideways方向。为了使移动速度与帧率无关,我们将计算一个速度变量,为每秒 30 个单位。

Matrix4 rotation = Matrix4::Rotation(yaw, Vector3(0, 1, 0));
	
	Vector3 forward = rotation * Vector3(0, 0, -1);
	Vector3 right = rotation * Vector3(1, 0, 0);
	
	float speed = 30.0f * dt;

然后,向前、向后或向侧面移动相机就很简单了,只需通过加上或减去旋转后的向量来修改位置变量。

if (Window::GetKeyboard()->KeyDown(KEYBOARD_W))
{
	position += forward * speed;
}
if (Window::GetKeyboard()->KeyDown(KEYBOARD_S)) 
{
	position -= forward * speed;
}
if (Window::GetKeyboard()->KeyDown(KEYBOARD_A)) 
{
	position -= right * speed;
}
if (Window::GetKeyboard()->KeyDown(KEYBOARD_D))
{
	position += right * speed;
}

如果你不太明白,下面是它的工作原理。当偏航角(图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_c++_12)为 0°时,向量图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_线性代数_16保持不变,位置变量将沿着负 z 轴移动。然而,如果偏航值为 45°,向量((0,0,-1))将旋转到大约图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_矩阵_17的值,这意味着移动将是对角线方向。如果偏航值为 90°,向量将等于图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_矩阵_18,此时移动将沿着负 x 轴。

要上下移动,我们将使用 shift 键和空格键。无论相机的朝向如何,我们都希望它能上下移动,这有一个简单的计算方法——我们只需按照速度增加或减少 y 轴的值,这样的移动更像是第一人称射击游戏风格的相机,而不是飞行模拟器风格的。

if (Window::GetKeyboard()->KeyDown(KEYBOARD_SHIFT)) {
		position.y += speed;
	}
	if (Window::GetKeyboard()->KeyDown(KEYBOARD_SPACE)) {
		position.y -= speed;
	}
}

BuildViewMatrix函数将构建我们的顶点着色器所需的视图矩阵。如前所述,这个视图矩阵是由相机的位置和旋转值创建的矩阵的逆矩阵。然而,对一个 4×4 的矩阵求逆在计算上是非常昂贵的——去某个地方查找代码,会很麻烦!如果我们愿意,可以使用 GLSL 的逆函数来隐藏对矩阵求逆的复杂细节,但我们要做一些有点狡猾的事情。在构建矩阵时,我们可以只对相机的俯仰角(图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_线性代数_11)、偏航角(图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_c++_12)和位置(图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_图形渲染_21)取反,这与对矩阵求逆的效果是一样的,但这是一个成本低得多的操作!

我们使用三个临时矩阵来构建实际的矩阵——两个用于通过取反后的俯仰角(图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_线性代数_11)和偏航角(图形学系列教程,带你从零开始入门图形学(包含配套代码)—— 视图矩阵 & 帧速率_c++_12)进行旋转,一个用于通过取反后的位置进行平移。记住,矩阵乘法的顺序很重要!如果我们把平移矩阵放在旋转矩阵之前,我们将围绕原点在相机位置的距离处进行旋转和平移,而不是在相机位置进行旋转。另外,注意每个旋转所围绕的轴。

Matrix4 Camera::BuildViewMatrix()
{
	return 	Matrix4::Rotation(-pitch, Vector3(1, 0, 0))*
			Matrix4::Rotation(-yaw, Vector3(0, 1, 0))*
			Matrix4::Translation(-position);
}

使用这个类

在你的项目中成功编译这个类后,你就可以在你的程序中使用它了。在上一个教程的Renderer类中添加一个Camera成员变量。你还需要实现从OGLRenderer类继承的虚函数UpdateScene。它具有以下函数签名:

//Renderer.h
virtual void UpdateScene ( float dt );

如你所见,它接收一个单精度浮点数作为参数。这个参数将表示自上次调用“UpdateScene”以来经过的秒数。然后,在你的Renderer类文件中,添加以下函数定义:

void Renderer::UpdateScene(float dt) {
	camera->UpdateCamera(dt);
	viewMatrix = camera->BuildViewMatrix();
}

现在,这个函数的目的应该更清楚了。它使用经过的适当秒数来更新你的新Camera类,然后构建一个新的视图矩阵,准备发送给你的着色器。正如你在上一个教程中看到的,将矩阵发送给着色器的代码有点笨拙,所以值得回顾一下OGLRenderer类,因为它带有一个名为UpdateShaderMatrices的方法,该方法试图将所有常用的矩阵发送给当前绑定的着色器(通过调用BindShader方法设置):

void OGLRenderer::UpdateShaderMatrices()	{
	if(currentShader) {
		glUniformMatrix4fv(glGetUniformLocation(currentShader->GetProgram(), "modelMatrix")   ,	1,false, modelMatrix.values);
		glUniformMatrix4fv(glGetUniformLocation(currentShader->GetProgram(), "viewMatrix")    ,	1,false, viewMatrix.values);
		glUniformMatrix4fv(glGetUniformLocation(currentShader->GetProgram(), "projMatrix")    ,	1,false, projMatrix.values);
		glUniformMatrix4fv(glGetUniformLocation(currentShader->GetProgram(), "textureMatrix") , 1,false, textureMatrix.values);
	}
}

这些 OpenGL 函数调用对你来说应该相当熟悉——我们在上一个教程中用它们来设置模型和投影矩阵。这次我们也要以完全相同的方式设置视图矩阵和纹理矩阵(在下一个教程中你将看到如何使用纹理矩阵!)。这只是一种减少Renderer类中重复代码量的方法。你可以用它来代替我们在教程 2 中使用的两次对glUniformMatrix4fv的调用,同时也方便地更新了我们的新视图矩阵!

最后,我们必须在游戏循环中调用UpdateScene,所以修改你在Tutorial2.cpp中的循环如下:

while (w.UpdateWindow() && !Window::GetKeyboard()->KeyDown(KEYBOARD_ESCAPE)) {
		// Tutorial 2 code goes here ...
		...
		...
		renderer.UpdateScene(w.GetTimer()->GetTimeDeltaSeconds());
		renderer.RenderScene();
	}

我们现在调用了UpdateScene,但是它如何获得正确的毫秒数呢?幸运的是,项目中有一个GameTimer类,当创建一个窗口时,它会在幕后被实例化。GameTimer类是对 CPU 上的高性能计数器的一个小包装,它跟踪自上次调用其函数GetTimeDeltaSeconds以来经过的秒数。所以,只要你只使用这个计时器来计时一件事情,在这种情况下是自上一帧以来的时间,你就总是有一个准确的计数器来使用,以保持你的相机以正确的速度移动。


总结

如果你在进行这些更改后重新运行你的第二个教程程序,你应该能够切换到透视模式,并使用鼠标改变俯仰角和偏航角来在你的简单三角形场景中移动,使用 W、A、S、D、Shift 和空格键在场景中移动。不是很令人兴奋,但你现在知道了什么是视图矩阵,如何使用它,以及如何在帧率可变的情况下保持你的计算正确。在下一个教程中,你将看到如何使用纹理贴图使你的几何图形看起来更逼真,并且还将了解如何使用纹理矩阵。

课后作业

  1. 如果你将视图矩阵乘以一个缩放矩阵会发生什么?结果和你预想的一样吗?
  2. 如果你改变BuildViewMatrix函数中旋转的顺序会怎样?
  3. Camera类目前使用一个简单的msec值来计算移动的距离。这对于你的游戏来说可能太快或太慢,所以尝试在类中添加一个速度成员变量——你甚至可以为每个轴设置一个单独的速度!你将如何在UpdateCameramsec参数中使用速度变量?
  4. Camera类目前围绕 X 轴和 Y 轴创建视图矩阵的旋转。尝试在Camera类中添加一个围绕 Z 轴旋转的roll成员变量。结果和你预想的一样吗?当你旋转 90 度时会怎样?

欢迎大家踊跃尝试,期待同学们的留言!!