在Qt中使用OpenGL(一)
在Qt中使用OpenGL(二)
在Qt中使用OpenGL(三)
在Qt中使用OpenGL(四)
在Qt中使用OpenGL(五)
在Qt中使用OpenGL(六)
在Qt中使用OpenGL(七)
在之前的文章中,我们从最简单的代码开始,逐步拥有了能够构建一个简单的3D世界的能力。
本篇文章,我们主要是为了之前的代码进行一些……怎么说呢?优化?
然后我们尝试画出一个“球体”出来, 模拟出来一个之前照亮了“宇宙中的三个色子”的光源。
相信不用我说,大家都能看出来我们之前绘制的图像有多么的粗糙了吧。
特别是模型边缘的各种锯齿。
虽然我们无法达到最高端游戏中的那种细腻的效果,但是我们也应该至少要尝试着补救一下,方法就是开启多重采样。
在Qt中,多重采样是和窗口紧密联系的,反而和OpenGL没有了多少关系。所以为了开启多重采样,我们需要设置窗口的“格式”。简单来说,就是我们要在3D窗口初始化的时候,通过格式设置多重采样:
auto newFormat = this->format();
newFormat.setSamples(16);
this->setFormat(newFormat);
在3D窗口的构造函数中添加这三句话。我们就可以开启多重采样了:
这就是开启了16倍多重采样的结果,是不是看起来没那么的锯齿化了?
OpenGL在绘制了之后,你是可以同时看到面的正面和背面的。
什么叫做正面?默认的,你看到一个面(三角形)的顶点是逆时针排列的那一面就是正面,顺时针排列的那一面就是背面。
但你有没有想过,就像我们画出来了一个色子,它的里面实际上我们是看不到的,也就是说它的背面对于在外面看是毫无意义的。但是OpenGL却会忠实的进行绘制。
为了节省资源,我们可以开启背面裁剪,方法也很简单,就是使用glEnable
函数,传入适当的参数即可:
glEnable(GL_CULL_FACE);
这样子,我们就可以开启背面裁剪,减少不必要的绘制了。
相信大家玩游戏的时候一定注意到了,很多游戏的UI其实并不是3D的。
那么,我们能不能在绘制3D图像的同时,使用Qt提供的函数,绘制2D的UI呢?
当然可以!只要在paintGL函数中直接使用QPainter函数即可。
不过我们在绘制2D的UI之前得先做一些事情:关闭深度测试以及关闭背面裁剪。
void OpenGLWidget::paintGL()
{
glEnable(GL_DEPTH_TEST);
glEnable(GL_CULL_FACE);
for (auto dice : m_models)
{
dice->paint();
}
QPainter _painter(this);
auto _rect = this->rect();
_painter.setPen(Qt::green);
_painter.drawLine(_rect.center() + QPoint{ 0, 5 }, _rect.center() + QPoint{ 0, 15 });
_painter.drawLine(_rect.center() + QPoint{ 0, -5 }, _rect.center() + QPoint{ 0, -15 });
_painter.drawLine(_rect.center() + QPoint{ 5, 0 }, _rect.center() + QPoint{ 15, 0 });
_painter.drawLine(_rect.center() + QPoint{ -5, 0 }, _rect.center() + QPoint{ -15, 0 });
_painter.drawText(QPoint{ 5, 15 }, QString(u8"摄像机位置: (%1, %2, %3)")
.arg(m_camera.pos().x(), 0, 'f', 3).arg(m_camera.pos().y(), 0, 'f', 3).arg(m_camera.pos().z(), 0, 'f', 3));
_painter.drawText(QPoint{ 5, 30 }, QString(u8"摄像机角度: (%1, %2, %3)")
.arg(m_camera.yaw(), 0, 'f', 3).arg(m_camera.pitch(), 0, 'f', 3).arg(m_camera.roll(), 0, 'f', 3));
}
当然,我们可以用上面的代码来先看看如果不这么做会发生什么:
注意,这里的文字显示还是正常的,可是我们花了大功夫试图画出来的十字架准星呢?
没错,看不到了。
现在,我们在QPainter构建之前加上两句话禁用深度测试和背面裁剪:
glDisable(GL_DEPTH_TEST);
glDisable(GL_CULL_FACE);
什么叫做线框模式?
就是你画出来的所有模型,都只剩下线框。
就是这个效果,连你UI上的文字都会变成莫名其妙的样子。
使用它其实主要是为了让你可以清楚的看到你的模型面的组成方法,这在之后我们试图绘制一个球体的时候有一些帮助。
那么,这个模式怎么使用呢?
确切的说,如果你只是用Qt中的OpenGL,可能无法达到这个效果。
因为Qt中的OpenGL,是OpenGLES,它没有实现这个功能。
不信吗?
把开启线框模式的代码加到你的代码里试试看:
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
编译一下:
看到了吗?根本找不到这个函数的实现!
不过不用怕,Qt里面没实现,我们可以用你的操作系统中实现的。
所以,我们只需要添加windows中实现OpenGL的库OpenGL32.lib就行了。
没错,哪怕你是64位程序,也可以用这个lib。
在你的工程中,在连接器的参数中加入这个额外的lib,编译就通过了。
当然,为了在线框模式下我们可以正常显示UI,还需要再绘制UI之前禁用这个模式:
glPolygonMode(GL_FRONT_AND_BACK, GL_FILL);
在一个三维世界,一个球应该是最简单的“复杂几何体”了,很显然,想要画出一个求,就必须使用很多个面。
绘制球的方法也不是只有一种,但是在这里我只说一种,我将它命名为地球仪绘制法。(实际上有更加准确的叫法,但是我懒得查)
先看一下成品:
首先,我们忽略掉倾斜的线,只看水平和垂直的线,有没有什么发现?
没错,和你常见到的地球仪是一样的,通过经纬线可以将地球分割成一个一个的“矩形”。
当然,这么做会在南北极失效:
毕竟它是个球,所以在南北极的时候,就不再是一个矩形,而是一个圆形的盖子了。
那么,现在我们就可以思考一下,要怎么画出这个球了。
还记得我将它命名成什么吗?
地球仪绘制法!
地球仪!
地图!
没错,世界地图!
我们生活在一个球形的行星上,可是地图却是矩形的!(当然有不是矩形的)
而且你可以在世界地图上清晰地看到经纬线!
看到了吗?这就是我们绘制球体的方案。
我们只要按照世界地图的经纬线绘制矩形就行了! 只要把经纬线交点对应到三维空间中的点即可。
那么,一个球体上,经纬线交叉点的坐标怎么计算呢?
不用过多的思考,我们依旧使用3D数学的逻辑来吧。
我们只需要使用一个向量,指向北极,也就是(0,1,0),然后,首先让它沿着x轴旋转,再沿着y轴旋转,x轴旋转10°是什么意思呢?默认我们的向量指向北极,转10°就意味着靠近赤道了10°,显然,此时,我们的向量指向的地方就是就是北纬80°,东经0°!
明白现在该干什么了吗?
没错,开启两个循环,让这个向量在地球上遍历一遍所有的经纬度,它就自然而然的和世界地图上的经纬度交点对上了,也就知道每个交点的三维坐标了!
QVector3D _top{ 0,1,0 };
float _step = 10;
QVector<QVector<QVector3D>> _vertexMatrix;
for (float _yaw = 0; _yaw <= 180; _yaw += _step)
{
_vertexMatrix << QVector<QVector3D>();
m_col = 0;
for (float _pitch = 0; _pitch < 360; _pitch += _step)
{
QMatrix4x4 _mat;
_mat.setToIdentity();
_mat.rotate(_yaw, 1, 0, 0);
_mat.rotate(-_pitch, 0, 1, 0);
auto _p = _top * _mat;
_vertexMatrix[m_row] << _p;
++m_col;
}
++m_row;
}
差不多就是这个意思。你可以把step设置的小一点,可能最终结果会稍微的精细一写。但是目前我们以10°来遍历整个地球的经纬线交点。
注意:我们在北极和南极的时候也遍历了,虽然最终所有所有的结果都是(0,1,0)和(0,-1,0)但是从和地图对应的角度上来说,这才是正确的对应关系。
注意:pitch旋转的时候使用了负角度,这是因为我们希望遍历结果可以和世界地图的坐标一样的从左到右,从上到下,使用-角度就可以实现这个要求。
此时你应该就意识到了,我们完成了这个步骤之后,也同时天然的就拿到了这个球体和世界地图的对应关系。就好像纹理映射关系一样。
还不赶快取下载一个世界地图过来?
我们绘制了这个球体之后就可以直接加上纹理画出一个地球了啊!
这是我从中国的标准地图服务网中下载然后裁剪的矩形投影的世界地图。
我不知道这里有没有版权问题……姑且算是没有吧……
那么,有了这个基本的映射结果,我们就可以根据这个结果创建球体的顶点了:
for (int y = 0; y < m_row-1; ++y)
{
for (int x = 0; x < m_col; ++x)
{
auto _p0 = _vertexMatrix[y][x];
auto _p1 = _vertexMatrix[y + 1][x];
int _nextX = x + 1;
if (_nextX == m_col)
{
_nextX = 0;
}
auto _p2 = _vertexMatrix[y + 1][_nextX];
auto _p3 = _vertexMatrix[y][_nextX];
m_vertices << Vertex{ { _p0.x(), _p0.y(), _p0.z() }, {(float)x / m_col, (float)y / m_row} }
<< Vertex{ { _p1.x(), _p1.y(), _p1.z() }, {(float)x / m_col, (float)(y + 1) / m_row} }
<< Vertex{ { _p2.x(), _p2.y(), _p2.z() }, {(float)(x + 1) / m_col, (float)(y + 1) / m_row} }
<< Vertex{ { _p3.x(), _p3.y(), _p3.z() }, {(float)(x + 1) / m_col, (float)y / m_row} };
}
}
同样的,我们按照矩形来绘制(虽然南北极的时候矩形被压缩成了三角形,贴图会不大正确,但是暂时就先这样吧),所以我们需要按照左上角,左下角,右下角,右上角这个顺序,从对应关系中提取出4个顶点,以及它们的纹理映射。
唯一需要注意的是,由于地球是一个球体,我们水平遍历的时候实际上少遍历了一个点,所以,我们需要额外的计算一下:水平方向上的最后一个点就是第一个点。(当然,你可以在之前的映射时多映射一个点,那么这里就可以不用增加额外的逻辑了)
既然纹理直接可以和地图对应上,那么我们就可以加载纹理了:
auto _texture = new QOpenGLTexture(QImage(":/world-map.jpg"));
_texture->setMinificationFilter(QOpenGLTexture::LinearMipMapLinear);
_texture->setMagnificationFilter(QOpenGLTexture::Linear);
setTexture(_texture);
对于shader,基本上就是最原始的,我们刚刚可以绘制纹理时的那个shader:
#version 330 core
in vec3 vPos;
in vec2 vTexture;
out vec2 TexCoords;
uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;
void main()
{
TexCoords = vTexture;
gl_Position = projection * view * model * vec4(vPos, 1.0);
}
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform vec3 lightColor;
uniform sampler2D Texture;
void main()
{
FragColor = vec4(texture(Texture, TexCoords).rgb * lightColor, 1.0);
}
唯一的变化,可能就是我们增加了一个灯光颜色。
毕竟实际上刚开始我想画这个球的时候,是为了表示这是一个光源。
光源有光的颜色很合理对吧。
接下来, 初始化缓存什么的没有任何变化,按照5个float一个顶点的格式来初始化与绑定就好了
这里唯一需要注意的就是绘制的时候,需要计算出来我们一共要绘制多少个矩形:
for (int i = 0; i < m_col * (m_row - 1); ++i)
{
glDrawArrays(GL_TRIANGLE_FAN, _index, 4);
_index += 4;
}
OK,让我们把这个绘制地球的模型加入到我们的场景中看看效果吧:
至今为止的代码可以从以下仓库中查看:
https://gitee.com/ninsunclosear/opengltest