在Qt中使用OpenGL(五)

本文是Qt使用OpenGL系列的第五篇,主要讨论3D视角控制的问题。通过创建摄像机类,实现了键盘控制摄像机的移动和鼠标调整视角。文章详细介绍了摄像机类的设计、视角变化的数学原理,以及如何监控键盘和鼠标操作来更新视图矩阵。

前言

在Qt中使用OpenGL(一)
在Qt中使用OpenGL(二)
在Qt中使用OpenGL(三)
在Qt中使用OpenGL(四)
上一篇的文章中,我们成功的绘制出了一个旋转的色子。此时的你一定不再满足于只绘制一个色子了,肯定迫不及待的决定要绘制点别的东西出来。
但是先不要着急。
因为到目前为止,我们还有一些问题没有解决。
首先就是视角问题。

视角控制

还记得我们是怎么控制视角的吗?
通过数学的方法来构建出来一个视图矩阵,模拟摄像机。
但是你有没有想过,要如何控制它呢?
难道每次咱们都需要准确的去定义摄像机在哪里,又要看向哪里吗?我们又要怎么知道这些坐标的准确的值?我们要怎么输入这些值?给用户一个文本框,让用户自己输入坐标吗?这也太不方便了。
我要是想要实现在3D的FPS游戏中一样,用键盘自由地来回移动,用鼠标自由的来回观察怎么办呢?
没错,聪明的你一定想到了。我们需要定义并管理一些变量,例如人物的坐标和人物的视角朝向,然后监控用户的键盘和鼠标操作,根据用户的操作动态的修改这些变量,并且最终返回给OpenGL一个基于这些信息的视图矩阵。
现在我们就开始做这些事情。

摄像机类

首先让我们设计一个摄像机类。
我们怎么定义这个类的接口呢?换句话来说,一个摄像机可以被用户怎么操作呢?
首先我们应该可以想到,一个摄像机,一定是可以在x,y,z三个轴的方向上自由运动的。并且不是那种我们给你定一个位置,而是我们可以基于当前的位置,决定要前后,左右,上下的运动。
此时,我们就需要注意了,因为我们在说的不是x,y,z三个轴,而是前后,左右,上下。
什么意思呢?当你朝向x轴的正方向,所谓的前后,就是沿着x轴前后运动。但是当你朝向y轴的正方向,所谓的前后,就是沿着y轴的前后运动。
你的运动方向,实际上是和你的朝向相关的。
于是,为了可以正确的处理这个问题,我们首先需要定义一个叫做前方的向量。默认值就用z轴的负方向好了,正好是默认情况下我们面向屏幕时前方的方向,也就是(0,0,-1)。
然后,我们知道摄像机是可以倾斜着拍摄的,但是正常情况下,我们的视角都是头朝上的视角,也就是默认情况下的y轴正方向,于是我们就可以定义一个叫做上方的向量,并且默认值为(0,1,0),正好对应着默认情况下我们面向屏幕时,上方的方向。
还缺什么吗?
当然,我们还缺少一个叫做位置的向量,也就是摄像机的位置。那么,我们就定义它,然后默认值设置为(0,0,0)好了。
接下来要做什么呢?
我们还需要好好的定义一下前方这个向量允许怎么变化的情况。
一般情况下,当我们抬头看,我们肯定不会考虑仰头超过90°对吧,低下头也不会考虑低下头超过90°,但是当我们水平查看的时候,虽然我们的脖子限制了我们的视角,但是考虑到我们可以自己转身,所以实际上水平的视角角度可以认为是不存在任何限制的。
由于我们可以不用考虑歪着头或者倒立的情况,所以我们的上方可以认为永远不变。
那么,以上分析我们要怎么表示好呢?
实际上。现实中,正好有与之相对的概念,就是偏航(yaw),俯仰(pitch)与翻滚(roll)。
对应关系如下:
偏航(yaw)对应左右看,在OpenGL的默认坐标系,代表着视角沿着y轴旋转。
俯仰(pitch)对应上下看,在OpenGL的默认坐标系,代表着视角沿着x轴旋转。
翻滚(roll)对应着歪头看,在OpenGL的默认坐标系,代表着视角沿着z轴旋转。
于是,我们就可以定义出来三个变量,分别代表yaw,pitch,roll。
最后,我们需要有一个矩阵代表视图矩阵,于是我们可以定义一个QMatrix4x4的变量。
于是,我们在类中有了以下定义:

private:
	QMatrix4x4 m_view;
	QVector3D m_pos{ 0,0,0 };
	QVector3D m_front{ 0,0,-1 };
	QVector3D m_up{ 0,1,0 };
	float m_yaw = 0;
	float m_pitch = 0;
	float m_roll = 0;

那么,接口怎么写呢?不可能直接修改这些变量吧。
还记得一般情况下,我们在FPS游戏中都有哪些操作吗?
移动位置和调整视角(跳跃也是移动位置的一种)。
那么,我们就可以定义两个函数,分别代表移动位置和调整视角。

public:
	void look(float yaw, float pitch, float roll);
	void move(float x, float y, float z);

位置移动

其中,位置移动很容易理解,x就是指前后运动的距离,y就是左右运动的距离,z就是上下运动的距离。
由于我们已经有了表示前方上方的向量,那么我们只需要借助向量叉乘,就可以计算出垂直于这两个方向的向量了。同时垂直于前方上方的向量,正好就是代表着右侧的向量。
其次,我们还需要考虑一个问题,那就是一般情况下,我们的运动都是水平的,即我们都会沿着水平方向运动而不是真的沿着我们的视角朝向的方向运动。比如我可以看着下方或者上方,但是我的运动方向依旧是保持水平运动,而不是运动的时候会穿过地面或者飞到空中。
想要达到这个目标也很简单,我们只需要再次使用上方和刚刚计算出来的右侧向量进行叉乘,就可以得到一个水平方向的前方向量了。
于是,对于move函数的实现,就可以这么写:

void Camera::move(float x, float y, float z)
{
	auto _right = QVector3D::crossProduct(m_front, m_up).normalized();
	auto _front = QVector3D::crossProduct(m_up, _right).normalized();
	m_pos += _front * x;
	m_pos += _right * y;
	m_pos += m_up * z;
}

我们充分借助了Qt提供的各种类中丰富的接口,计算出标准化的右侧向量与前方向量,然后让摄像机坐标朝向着这些方向运动。

视角变化

视角的变化则是需要使用之前提到的yaw,pitch与roll的概念了。
我们知道yaw是指左右看,没有什么限制,但是考虑到一周只有360°,那么我们还是将其限制在360°比较好。
而pitch是上下看,一旦我们的视角和表示这上的这个向量方向一致时,很显然我们将无法区分真正的上在哪里了,所以我们需要将pitch限制在±90°之内且不允许为±90°。那么我们就用±89°作为上下的极值好了,1°的差异不会有很大的问题。
而roll这个代表着歪头看的情况,我们目前可以完全不用考虑。
这么一来,剩下的就是怎么让前方这个向量,基于yaw和pitch进行变化了。
其实方法也很简单,依旧是利用3D数学,通过矩阵对向量进行旋转即可。
也就是让默认的前方向量(0,0,-1)先绕着x轴旋转,之后再绕着y轴旋转。
注意,pitch是上下看,代表绕x轴旋转。yaw是左右看,代表绕y轴旋转。
于是我们就可以将代码写成这样:

void Camera::look(float yaw, float pitch, float roll)
{
	m_yaw += yaw;
	while (m_yaw >= 360)
		m_yaw -= 360;
	while (m_yaw < 0)
		m_yaw += 360;
	m_pitch += pitch;
	if (m_pitch > 89)
		m_pitch = 89;
	if (m_pitch < -89)
		m_pitch = -89;
	{
		QVector3D _front{ 0,0,-1 };
		QMatrix4x4 _mat;
		_mat.setToIdentity();
		_mat.rotate(m_pitch, 1, 0, 0);
		_mat.rotate(m_yaw, 0, 1, 0);
		m_front = _front * _mat;
	}
}

那么我们怎么计算最终的视图矩阵呢?
很简单,和上一篇的文章中的代码没多大区别,只需要额外的计算一下要看的位置即可,也就是:

m_view.setToIdentity();
m_view.lookAt(m_pos, m_pos + m_front, m_up);

监控按键与鼠标操作

已知,我们的摄像机类是一个普通的类,并不是一个窗口类。
那么,我们要如何在一个摄像机类中,监控三维窗口中,用户的键盘与鼠标操作呢?
难道提供相应的接口,然后在三维窗口中监控这些操作然后再传递到摄像机类中?
Qt提供了一种机制,可以在一个QObject的子类中,监控其它类的事件。
而用户的键盘与鼠标操作正好就是三维窗口类的事件。
监控的方法也很简单,首先,我们将摄像机类定义为QObject类的子类,然后在三维窗口类中创建摄像机类,然后将摄像机类作为事件过滤器进行安装即可。
即,我们的摄像机类可以这么定义:

#pragma once
#include <QObject>
#include <QMatrix4x4>
#include <QVector3D>
class Camera : public QObject
{
	Q_OBJECT
public:
	Camera(QObject *parent = nullptr);
	~Camera();
public:
	void look(float yaw, float pitch, float roll);
	void move(float x, float y, float z);
public:
	void update();
	QMatrix4x4 view() { return m_view; }
protected:
	bool eventFilter(QObject *obj, QEvent *ev) override;
private:
	QMatrix4x4 m_view;
	QVector3D m_pos{ 0,0,0 };
	QVector3D m_front{ 0,0,-1 };
	QVector3D m_up{ 0,1,0 };
	float m_yaw = 0;
	float m_pitch = 0;
	float m_roll = 0;
}

其中,eventFilter表示本类可以用于监控在其它类中发生的事件。
注意摄像机类有一个update函数,它的作用为计算最终的运动距离和最终的视图矩阵:

void Camera::update()
{
	auto _move = m_move.normalized() * m_moveSpeed;
	move(_move.x(), _move.y(), _move.z());

	m_view.setToIdentity();
	m_view.lookAt(m_pos, m_pos + m_front, m_up);
}

什么叫做计算最终的运动距离呢?
我们不能将摄像机类设计成按一下按钮,前进一定距离,然后就需要松开按钮,再按下,再前进对吧。
我们需要的是按下一个按钮,摄像机就会持续的前进,不按按钮,摄像机就会停止前进。
所以,当我们检测到用户按下了键盘,我们要做的是记录一些信息,而不是让摄像机立刻运动。而是直到必要的时刻再去计算最终运动的距离。
然后,我们再考虑视角变化的情况。
视角变化很直接,比如鼠标一旦移动,就变化,不需要什么等待。
但是,你一定要注意一个问题:你的窗口大小不是无限大的。
可你水平旋转却是可以无限旋转的。
你又要怎么处理这个问题呢?
一个简单的方案如下:
鼠标在窗口内点击后,开始捕获鼠标将其移动到窗口中心的位置。并且在检测到运动后立刻将其再次移动到窗口的中心位置。
这样,无论你怎么移动鼠标,当我们检测到了这个移动,就立刻将你的鼠标移动回窗口的中心位置。
这样,我们就可以知道你的鼠标每次都移动了多远,还能保证你的鼠标可以永远的移动下去,即不会离开窗口范围,也不会因为屏幕范围不够而无法继续移动。
要想不再捕获鼠标,我们只需要按下Esc键即可。
具体的,代码如下:

bool Camera::eventFilter(QObject *obj, QEvent *ev)
{
	auto _widget = qobject_cast<QOpenGLWidget *>(obj);
	if (_widget)
	{
		if (ev->type() == QEvent::KeyPress)
		{
			auto event = static_cast<QKeyEvent *>(ev);
			if (event->key() == Qt::Key_Escape)
			{
				_widget->setMouseTracking(false);
				_widget->setCursor(Qt::ArrowCursor);
			}
			else if (event->key() == Qt::Key_W)
			{
				m_move.setX(1);
			}
			else if (event->key() == Qt::Key_S)
			{
				m_move.setX(-1);
			}
			else if (event->key() == Qt::Key_A)
			{
				m_move.setY(-1);
			}
			else if (event->key() == Qt::Key_D)
			{
				m_move.setY(1);
			}
			else if (event->key() == Qt::Key_Space)
			{
				m_move.setZ(1);
			}
			else if (event->key() == Qt::Key_C)
			{
				m_move.setZ(-1);
			}
		}
		else if (ev->type() == QEvent::KeyRelease)
		{
			auto event = static_cast<QKeyEvent *>(ev);
			if (event->key() == Qt::Key_W)
			{
				m_move.setX(0);
			}
			else if (event->key() == Qt::Key_S)
			{
				m_move.setX(0);
			}
			else if (event->key() == Qt::Key_A)
			{
				m_move.setY(0);
			}
			else if (event->key() == Qt::Key_D)
			{
				m_move.setY(0);
			}
			else if (event->key() == Qt::Key_Space)
			{
				m_move.setZ(0);
			}
			else if (event->key() == Qt::Key_C)
			{
				m_move.setZ(0);
			}
		}
		else if (ev->type() == QEvent::MouseButtonPress)
		{
			auto _lastPos = _widget->mapToGlobal(_widget->rect().center());
			QCursor::setPos(_lastPos);

			_widget->setMouseTracking(true);
			_widget->setCursor(Qt::BlankCursor);
		}
		else if (ev->type() == QEvent::MouseMove)
		{
			auto event = static_cast<QMouseEvent *>(ev);
			auto _lastPos = _widget->mapToGlobal(_widget->rect().center());
			QCursor::setPos(_lastPos);

			auto _move = event->globalPos() - _lastPos;
			look(_move.x() * m_lookSpeed, _move.y() * m_lookSpeed, 0);
		}
		else if (ev->type() == QEvent::Leave)
		{
			_widget->setMouseTracking(false);
			_widget->setCursor(Qt::ArrowCursor);
		}
	}
	return false;
}

我们首先获取这个事件的来源。
通过

auto _widget = qobject_cast<QOpenGLWidget *>(obj);

就可以将事件来源转换为QOpenGLWidget窗口。如果转换失败,就表示事件来源有问题了,不处理即可。
然后,我们检测键盘按下了哪个键。
Esc键表示不再捕获鼠标,对应到Qt中就是窗口不再追踪鼠标,即

_widget->setMouseTracking(false);

其中WS键表示前后运动,我们使用QVector3D的X值保存1和-1表示按下了W还是S
左右与上下运动类型,分别用Y值和Z值保存。
当我们松开了按键,则对应的X,Y,Z值清理,表示不再进行运动了。

检测鼠标事件的时候,只有当窗口追踪鼠标时,我们才会在没有按下左键时检测到鼠标运动。
此时,我们直接使用鼠标的位置和窗口中心的差值作为我们要进行视角移动的角度(当然,还可以通过一个变量来控制视角移动的速度)。然后再将鼠标移动回窗口的中心。
需要注意的是,视角移动时,鼠标在x轴上移动,实际上代表的是视角沿y轴旋转,鼠标在y轴上移动,实际上代表的是视角沿x轴旋转。
这其实也是为什么是look函数的参数顺序要使用yaw,pitch,roll的缘故。因为正好可以和二维空间中鼠标的的x,y轴运动对应起来。

使用摄像机类

我们的摄像机类已经写好了,现在就可以试试看。
首先,我们在三维窗口类中将摄像机类作为成员变量加入。

Camera m_camera;

然后,在构造函数中进行初始化,初始化默认的位置(不要忘记更新一下,计算位置和视图矩阵),和安装事件过滤器以便可以监控用户的操作。

OpenGLWidget::OpenGLWidget(QWidget *parent)
	: QOpenGLWidget(parent)
{
	startTimer(1000 / 60);
	
	m_camera.move(-6, 0, 3);
	m_camera.look(0, 30, 0);
	m_camera.update();

	installEventFilter(&m_camera);
}

接下来在三维窗口类的timerEvent函数中调用摄像机的update函数,以便定时计算运动位置和视图矩阵。

void OpenGLWidget::timerEvent(QTimerEvent *event)
{
	m_camera.update();
	...
	...
	...
}

别忘了在paintGL函数中使用摄像机类提供的视图矩阵:

m_program->setUniformValue("view", m_camera.view());

让我们看看效果如何:
在这里插入图片描述
下一篇: Qt中使用OpenGL(六)

评论 13
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值