调整摄像机位置观测空间
摄像机/观察空间
当我们讨论摄像机/观察空间(Camera/View Space)的时候,是在讨论以摄像机的视角作为场景原点时场景中所有的顶点坐标:观察矩阵把所有的世界坐标变换为相对于摄像机位置与方向的观察坐标。要定义一个摄像机,我们需要它在世界空间中的位置、观察的方向、一个指向它右测的向量以及一个指向它上方的向量。
摄像机位置
摄像机位置简单来说就是世界空间中一个指向摄像机位置的向量:
QVector3D cameraPos = QVector3D(0.0f, 0.0f, 3.0f);
不要忘记正z轴是从屏幕指向你的,如果我们希望摄像机向后移动,我们就沿着z轴的正方向移动。
摄像机方向
现在我们让摄像机指向场景原点:(0, 0, 0)
。用场景原点向量减去摄像机位置向量的结果就是摄像机的指向向量。由于我们知道摄像机指向z
轴负方向,但我们希望方向向量(Direction Vector)指向摄像机的z
轴正方向。如果我们交换相减的顺序,我们就会获得一个指向摄像机正z
轴方向的向量:
QVector3D cameraTarget = QVector3D(0.0f, 0.0f, 0.0f);
QVector3D cameraDirection = QVector3D(cameraPos - cameraTarget);
cameraDirection.normalize();
右轴
我们需要的另一个向量是一个右向量(Right Vector),它代表摄像机空间的x
轴的正方向。为获取右向量我们需要先使用一个小技巧:先定义一个上向量(Up Vector)。接下来把上向量和第二步得到的方向向量进行叉乘。两个向量叉乘的结果会同时垂直于两向量,因此我们会得到指向x
轴正方向的那个向量(如果我们交换两个向量叉乘的顺序就会得到相反的指向x
轴负方向的向量):
QVector3D up = QVector3D(0.0f, 1.0f, 0.0f);
QVector3D cameraRight = QVector3D::crossProduct(up, cameraDirection);
cameraRight.normalize();
上轴
现在我们已经有了x
轴向量和z
轴向量,获取一个指向摄像机的正y
轴向量就相对简单了:我们把右向量和方向向量进行叉乘:
QVector3D cameraUp =
QVector3D::crossProduct(cameraDirection, cameraRight);
在叉乘和一些小技巧的帮助下,我们创建了所有构成观察/摄像机空间的向量。
Look At
我们有了3个相互垂直的轴和一个定义摄像机空间的位置坐标,我们可以创建我们自己的LookAt矩阵了:
L o o k A t = [ R x R y R z 0 U x U y U z 0 D x D y D z 0 0 0 0 1 ] ∗ [ 1 0 0 − P x 0 1 0 − P y 0 0 1 − P z 0 0 0 1 ] 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}LookAt=⎣⎢⎢⎡RxUxDx0RyUyDy0RzUzDz00001⎦⎥⎥⎤∗⎣⎢⎢⎡100001000010−Px−Py−Pz1⎦⎥⎥⎤
其中R \color{red}RR是右向量,U \color{green}UU是上向量,D \color{blue}DD是方向向量P \color{purple}PP是摄像机位置向量。注意,位置向量是相反的,因为我们最终希望把世界平移到与我们自身移动的相反方向。把这个LookAt
矩阵作为观察矩阵可以很高效地把所有世界坐标变换到刚刚定义的观察空间。LookAt
矩阵就像它的名字表达的那样:它会创建一个看着(Look at)给定目标的观察矩阵。
我们要做的只是定义一个摄像机位置,一个目标位置和一个表示世界空间中的上向量的向量(我们计算右向量使用的那个上向量)。接着使用QT封装的库即可创建一个LookAt矩阵,我们可以把它当作我们的观察矩阵:
QMatrix4x4 view;
view.lookAt(cameraPos, cameraTarget, QVector3D(0.0f, 1.0f, 0.0f));
接下来,我们会将摄像机的注视点保持在(0, 0, 0)。基于三角函数,在每一帧创建一个x
和z
坐标,它会代表圆上的一点,它作为摄像机的位置。通过重新计算x
和y
坐标,遍历圆上的所有点,这样摄像机就会绕着场景旋转了。我们预先定义这个圆的半径radius
,在每次渲染迭代中使用时间重新创建观察矩阵,来扩大这个圆。
const float radius = 10.0f;
time = m_time.elapsed()/1000.0;
float camX = sin(time) * radius;
float camZ = cos(time) * radius;
view.lookAt(QVector3D(camX, 0.0, camZ),
cameraTarget, up);
整体代码:
#include <QTime>
#include <QTimer>
#include <cmath>
class ZjjOpenGLWidget : public QOpenGLWidget,QOpenGLFunctions_4_5_Core
{
......
QTime m_time;
......
};
ZjjOpenGLWidget::ZjjOpenGLWidget(QWidget *parent) : QOpenGLWidget(parent)
{
setFocusPolicy(Qt::StrongFocus);
connect(&timer, SIGNAL(timeout()), this, SLOT(on_timeout()));
timer.start(200);
m_time.start();
}
void ZjjOpenGLWidget::paintGL()
{
......
QMatrix4x4 model;
QMatrix4x4 view;
QMatrix4x4 projection;
float time = QTime::currentTime().msec();
// x轴方向旋转-45°
model.rotate(-45, 1.0f, 0.0f, 0.0f);
// 全局坐标系下移动-1(z轴)
// view.translate(0.0, 0.0, -6);
QVector3D cameraPos = QVector3D(0.0f, 0.0f, 12.0f);
QVector3D cameraTarget = QVector3D(0.0f, 0.0f, 0.0f);
QVector3D cameraDirection = QVector3D(cameraPos - cameraTarget);
cameraDirection.normalize();
QVector3D up = QVector3D(0.0f, 1.0f, 0.0f);
QVector3D cameraRight = QVector3D::crossProduct(up, cameraDirection);
cameraRight.normalize();
QVector3D cameraUp =
QVector3D::crossProduct(cameraDirection, cameraRight);
const float radius = 10.0f;
time = m_time.elapsed()/1000.0;
float camX = sin(time) * radius;
float camZ = cos(time) * radius;
view.lookAt(QVector3D(camX, 0.0, camZ),
cameraTarget, up);
projection.perspective(45, (float)width()/height(), 0.1, 100);
......
}
可以看到我们像坐着旋转木马观察一个点。
键盘控制摄像机移动
之前我们在OpenGL(QT平台)学习与实战(十一)实验过使用键盘调节纹理的透明度。所以让键盘控制摄像机视角移动在代码实现方面其实差不多。
首先定义一个方向向量用于表征摄像机当前目标向量:
QVector3D cameraFront;
......
cameraFront = QVector3D(0.0, 0.0, -1.0);
cameraPos = QVector3D(0.0f, 0.0f, 12.0f);
LookAt
函数现在成了:
view.lookAt(cameraPos, cameraPos+cameraFront, up);
在之前的键盘输入基础上加上相应的逻辑:
void ZjjOpenGLWidget::keyPressEvent(QKeyEvent *event)
{
float cameraSpeed = 1;
switch (event->key()) {
case Qt::Key_Up:
ratio+=0.1;
break;
case Qt::Key_Down:
ratio-=0.1;
break;
case Qt::Key_W:
cameraPos += cameraSpeed*cameraFront;
break;
case Qt::Key_S:
cameraPos -= cameraSpeed*cameraFront;
break;
case Qt::Key_D:
cameraPos += cameraSpeed*cameraRight;
break;
case Qt::Key_A:
cameraPos -= cameraSpeed*cameraRight;
break;
default:
break;
}
if(ratio>1) ratio=1;
if(ratio<0) ratio=0;
shaderProgram.bind();
shaderProgram.setUniformValue("ratio", ratio);
update();
}
摄像机移动速度
目前我们的移动速度是个常量。理论上没什么问题,但是实际情况下根据处理器的能力不同,有些人可能会比其他人每秒绘制更多帧,也就是以更高的频率调用processInput函数。结果就是,根据配置的不同,有些人可能移动很快,而有些人会移动很慢。当你发布你的程序的时候,你必须确保它在所有硬件上移动速度都一样。
图形程序和游戏通常会跟踪一个时间差(Deltatime)变量,它储存了渲染上一帧所用的时间。我们把所有速度都去乘以deltaTime值。结果就是,如果我们的deltaTime很大,就意味着上一帧的渲染花费了更多时间,所以这一帧的速度需要变得更高来平衡渲染所花去的时间。使用这种方法时,无论你的电脑快还是慢,摄像机的速度都会相应平衡,这样每个用户的体验就都一样了。
我们跟踪两个全局变量来计算出deltaTime值:
float deltaTime = 0.0f; // 当前帧与上一帧的时间差
float lastFrame = 0.0f; // 上一帧的时间
在每一帧中我们计算出新的deltaTime以备后用。参考我们之前定义的一个m_timeout()
函数用于箱子的自旋转:
void ZjjOpenGLWidget::on_timeout()
{
float currentFrame = m_time.elapsed()/1000.0;
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;
update();
}
现在我们有了deltaTime,在计算速度的时候可以将其考虑进去了:
void ZjjOpenGLWidget::keyPressEvent(QKeyEvent *event)
{
float cameraSpeed = 2.5*deltaTime;
......
}
与前面的部分结合在一起,我们有了一个更流畅点的摄像机系统。
鼠标控制摄像头的姿态
偏航角和俯仰角是通过鼠标(或手柄)移动获得的,水平的移动影响偏航角,竖直的移动影响俯仰角。它的原理就是,储存上一帧鼠标的位置,在当前帧中我们当前计算鼠标位置与上一帧的位置相差多少。如果水平/竖直差别越大那么俯仰角或偏航角就改变越大,也就是摄像机需要移动更多的距离。
我们需要设置一个鼠标移动事件的捕获函数:
#include <QMouseEvent>
#define PI 3.1415926
class ZjjOpenGLWidget : public QOpenGLWidget,QOpenGLFunctions_4_5_Core
{
......
void mouseMoveEvent(QMouseEvent *event);
......
QPoint deltaPos;
......
};
在处理FPS风格摄像机的鼠标输入的时候,我们必须在最终获取方向向量之前做下面这几步:
- 计算鼠标距上一帧的偏移量。
- 把偏移量添加到摄像机的俯仰角和偏航角中。
- 对偏航角和俯仰角进行最大和最小值的限制。
- 计算方向向量。
第一步是计算鼠标自上一帧的偏移量。我们必须先在程序中储存上一帧的鼠标位置,我们把它的初始值设置为屏幕的中心(屏幕的尺寸是width() x height()
):
ZjjOpenGLWidget::ZjjOpenGLWidget(QWidget *parent) : QOpenGLWidget(parent)
{
setFocusPolicy(Qt::StrongFocus);
setMouseTracking(true);
......
}
void ZjjOpenGLWidget::mouseMoveEvent(QMouseEvent *event)
{
static float yaw = -90;
static float pitch = 0;
static QPoint lastPos(width()/2, height()/2);
auto currentPos = event->pos();
//然后在鼠标的回调函数中我们计算当前帧和上一帧鼠标位置的偏移量:
deltaPos = currentPos - lastPos;
lastPos = currentPos;
//注意我们把偏移量乘以了sensitivity(灵敏度)值。如果我们忽
//略这个值,鼠标移动就会太大了
float sensitivity = 0.1f;
deltaPos *= sensitivity;
//把偏移量加到全局变量pitch和yaw上
yaw += deltaPos.x();
pitch -= deltaPos.y();
// qDebug()<<deltaPos.x()<<", "<<deltaPos.y();
if(pitch > 89.0f) pitch = 89.0f;
if(pitch < -89.0f) pitch = -89.0f;
cameraFront.setX(cos(yaw*PI/180)*cos(pitch*PI/180));
cameraFront.setY(sin(pitch*PI/180));
cameraFront.setZ(sin(yaw*PI/180)*cos(pitch*PI/180));
cameraFront.normalize();
update();
}
我们需要给摄像机添加一些限制,这样摄像机就不会发生奇怪的移动了(这样也会避免一些奇怪的问题)。对于俯仰角,要让用户不能看向高于89度的地方(在90度时视角会发生逆转,所以我们把89度作为极限),同样也不允许小于-89度。这样能够保证用户只能看到天空或脚下,但是不能超越这个限制。我们可以在值超过限制的时候将其改为极限值来实现。
注意我们没有给偏航角设置限制,这是因为我们不希望限制用户的水平旋转。
最后一步,就是通过俯仰角和偏航角来计算以得到真正的方向向量。
缩放
视野(Field of View)或fov定义了我们可以看到场景中多大的范围。当视野变小时,场景投影出来的空间就会减小,产生放大(Zoom In)了的感觉。我们会使用鼠标的滚轮来放大。
void ZjjOpenGLWidget::wheelEvent(QWheelEvent *event)
{
if(fov >= 1.0f && fov <= 75.0f)
{
fov -= event->angleDelta().y()/120;
}
if(fov <= 1.0f) fov = 1.0f;
if(fov >= 75.0f) fov = 75.0f;
}
当滚动鼠标滚轮的时候,event->angleDelta().y()
值代表我们竖直滚动的大小。当wheelEvent
函数被调用后,我们改变全局变量fov
变量的内容。因为45.0f
是默认的视野值,我们将会把缩放级别(Zoom Level)限制在1.0f
到75.0f
。
我们现在在每一帧都必须把透视投影矩阵上传到GPU,但现在使用fov
变量作为它的视野:
projection.perspective(fov, (float)width()/height(), 0.1, 100);
现在,我们就实现了一个简单的摄像机系统了,它能够让我们在3D环境中自由移动。