Why OpenGL
Q: 为什么要使用OpenGL呢,用QPainter不好吗,在QOpenGLWidget里QPainter还可以有GPU加速呢。
A(ChatGPT): QPainter在QOpenGLWidget中确实可以使用GPU来加速渲染,但是它的性能可能不如直接使用OpenGL。因为QPainter在绘制时会经过多个抽象层,涉及到更多的CPU和GPU通信开销。另外,QPainter主要用于2D绘图,而OpenGL具有更强的3D绘图能力和灵活性,可以进行更复杂的渲染操作。如果你需要实现较为复杂的2D游戏之类的,建议使用OpenGL来完成,这样可以更好地控制绘图流程,并获得更高的性能表现。
也许不学纯OpenGL就接触不了底层原理,但是我希望读者可以通过此篇博客学到一些基本的东西
前言(后面加上的)
先开始写第一篇学习笔记的时候没有怎么想就开始写了,写到后面发现光靠ChatGPT来学习OpenGL是不够的于是和b站视频一起学习了
此篇文章主要针对那些会Qt,但是没有接触过OpenGL的人看的
如果您之前并不熟练使用Qt那我建议您先熟练学习Qt后再来此篇文章
学习笔记
000. 第一个基于Qt的OpenGL程序 画一个三角形
不多bb,先上代码,这些可以先不理解,但是要先混个眼熟
// "mainwindow.h"
// 为了方便定义直接写在头文件了
#include <QtOpenGLWidgets/QOpenGLWidget>
#include <QOpenGLFunctions>
class MyGLWidget: public QOpenGLWidget, protected QOpenGLFunctions {
public:
MyGLWidget(QWidget* parent = nullptr) {}
~MyGLWidget() {}
void initializeGL() {
initializeOpenGLFunctions();
glClearColor(0, 0, 0, 1);
}
void paintGL() {
glClear(GL_COLOR_BUFFER_BIT);
glColor4f(1,1,1,1);
glBegin(GL_TRIANGLES);
glVertex3f(-1.0f, 1.0f, 0.0f);
glVertex3f(0.5f, -0.5f, 0.0f);
glVertex3f(0.0f, 0.5f, 0.0f);
glEnd();
}
};
#include "mainwindow.h"
#include <QApplication>
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
MyGLWidget w;
w.show();
return a.exec();
}
创建一个你自己的窗口类
首先我们创建一个自己的类,继承自QOpenGLWidget与QOpenGLFunctions
- QOpenGLWidget 让你的窗口支持OpenGL并且提供了基本接口
- QOpenGLFunctions 这里面封装了很多类,让你可以不再创建使用函数指针去做一些opengl相关的事情
注意:项目中要引用QOpenGLWidgets和OpenGL,在cmake中你可以这样子做
find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets OpenGLWidgets)
target_link_libraries(000_FirstOpenGLDemo # 这里换成你的项目名
PRIVATE Qt${QT_VERSION_MAJOR}::Widgets
PRIVATE Qt${QT_VERSION_MAJOR}::OpenGL
PRIVATE Qt${QT_VERSION_MAJOR}::OpenGLWidgets
)
看一些基本的函数
这里我们用到了QOpenGLWidget的两个函数,一个是initializeGL()和paintGL()
- initializeGL 这个函数被调用做OpenGL的初始化工作(如:设置OpenGL状态、编译并链接着色器程序、加载纹理),一般只被调用一次。 在此实例中调用了initializeOpenGLFunctions();来初始化QOpenGLFunctions中提供的函数,也调用了glClearColor(0, 0, 0, 1);来决定使用glClear清除当前窗口中的像素时用什么颜色来替换(清除:实质上是用上面设置的glClearColor来替换窗口中像素的颜色)
- paintGL 和paintEvent一样是一个事件处理函数,用于处理QOpenGLWidget需要重绘的请求
- glVertex3f 这个是指定一个顶点,一般用于绘制图像决定图像的顶点
细看paintGL()里面的结构
- glClear(GL_COLOR_BUFFER_BIT); 清除窗口中的像素颜色
- glColor4f(1,1,1,1); 指定渲染颜色(好像有点不规范没写成1.0f)
- glBegin(GL_TRIANGLES);到glEnd(); 规定在这里面开始绘制
这里只是简单的说了下里面的函数的功能。但是之前用过QPainter的读者可能已经看出来了,这个结构好像和用QPainter绘图差不了太多??
的确,QPainter绘图无非也干这些事:
- 先清除屏幕上的内容(无需手动执行但是确确实实做了)
- 设置笔刷颜色
- 创建QPainter(创建的同时如果指定了绘图对象那么自动调用QPainter::begin(device);)
- 画完图之后painter.end();
一些小细节
这些只是我发现的,你们自己可以小改一些代码看看效果
- 在paintGL时,若没有设置,世界坐标0,0,0一般在屏幕正中央
- glVertex3f中虽然有3个顶点但是只有前面两个有效,这是因为QOpenGLWidget默认用来绘制2D图像,而这个顶点位置的第三个也就是z坐标影响不了在2D绘制下的投影。(但是改了摄像机位置就不一定的)
001. 一些OpenGL的术语
从ChatGPT那里问到了一些东西,待会我会更通俗解释
- 顶点(Vertex):3D模型中的点,它们被组织成三角形、四边形等基本几何图形来构建整个模型。
- 着色器(Shader):用于在GPU上执行运算的程序。着色器分为顶点着色器(Vertex Shader)和片段着色器(Fragment Shader)。
- 顶点缓冲区(Vertex Buffer Object, VBO):用于存储顶点数据的缓冲区。
- 索引缓冲区(Index Buffer Object, IBO):用于存储索引数据的缓冲区。
- 纹理(Texture):2D或3D图像,在渲染场景时可以将纹理映射到几何图形表面上。
- 光照(Lighting):指定光源和材质属性,计算场景中物体的明暗程度。
- 投影(Projection):将3D场景投影到2D平面上的过程。
- 视图(View):定义摄像机的位置和方向,用于观察场景。
- 视口(Viewport):渲染结果最终呈现的屏幕区域。
- 渲染管线(Rendering Pipeline):图形渲染的处理流程,包括顶点着色器、光照计算、裁剪、投影、像素着色器等。
- 光栅化 这个是自己加的等会我来解释
着色器
对于着色器可能还是有一点疑惑,于是又怕讲错又问了ChatGPT,不得不说人工智能太强大了
- 顶点着色器(Vertex Shader) 顶点着色器是在GPU上对输入的3D模型的每一个顶点进行单独处理的程序。通过顶点着色器,可以实现从应用程序中传递顶点属性并根据需要对它们进行变换等操作。具体来说,顶点着色器通常会执行以下任务:
- 对每个顶点的位置信息进行变换(例如,将其乘以投影矩阵)。
- 计算每个顶点的颜色、法向量等属性,并将其传递给片段着色器。
- 片段着色器(Fragment Shader) 片段着色器是在GPU上对每个屏幕像素进行单独处理的程序。它会接收从顶点着色器传递过来的颜色、法向量等属性,并计算每个像素的最终颜色。片段着色器通常会执行以下任务:
- 对每个像素进行光照计算,计算每个像素的最终颜色。
- 使用纹理等技术对每个像素进行着色。
- 计算每个像素的深度值和模板值,用于深度测试、剪裁等。 简单理解就是:顶点着色器是负责计算顶点的,片段着色器是计算颜色的,包括一些变换等
缓冲区
对于缓冲区,我先开始看到的时候,也是一脸懵逼。只能说之前经常听到这个词,但是具体是啥根本不知道,但是简单的来说就是:将特定的数据存储到显存中方便GPU调用,省去了存储在内存中还需要CPU传递给GPU的开销
纹理
这个玩意就是一个图像,不过能够映射到复杂的图形上面罢了。一般不需要我们知道怎么去制作,在后面我们会学习如何去加载它
光栅化
光栅化包括了将顶点投射到屏幕上的过程,但它同时也包括了计算出每个像素点的颜色的过程
002. OpenGL的核心模式
也叫可编程管线提供了更多了灵活性,更高的效率,同时还能够更深层次理解图形编程
一般分为以下几个步骤:
- 接收顶点数据
- 图元装配
- 几何着色器
- 光栅化
- 片段着色器
- 测试和混合
但是可以编程的部分只有:接受顶点数据,集合计算,片段着色器
图元装配
图元装配(Primitive Assembly)是图形学中的一个阶段,它是在顶点处理之后、光栅化之前的过程。在这个阶段,图形处理器会将顶点转换为几何图元(如三角形、线段等),并按照特定的规则对它们进行组合和排序,以便进行下一步的处理。具体来说,图元装配的主要任务是将每个顶点组合成几何图元,并确定它们的绘制顺序。在三角形渲染中,通常会将每3个相邻的顶点组成一个三角形,然后根据需要对三角形进行裁剪、背面剔除和深度测试等操作,最终生成屏幕上可见的像素点。
几何着色器
几何着色器(Geometry Shader)是图形学中的一个可编程着色器阶段,在顶点着色器之后、光栅化之前执行。它可以对每个几何图元(如三角形、线段等)进行处理,并生成新的几何图元。
几何着色器具有以下一些用途:
- 几何图元扩展:可以将一个输入的几何图元扩展为多个输出几何图元,例如在几何着色器中将一个点扩展为一个立方体或球体。
- 粒子系统:可以使用几何着色器来实现复杂的粒子系统,例如烟雾、火焰、水流等效果,而无需从CPU传输更多数据。
- 几何变换:可以在几何着色器中对几何图元进行变换操作,例如旋转、缩放、平移等,这些操作通常比在CPU上进行更加高效。
- 雾效果:可以使用几何着色器来实现雾效果,例如通过在几何着色器中计算每个像素点到摄像机的距离并将其与设定的雾参数进行比较。
总之,几何着色器可以扩展渲染管线的功能,使得开发者能够实现更加复杂和细致的场景渲染效果,提高图形软件的可玩性和真实感。
003. OpenGL对象
OpenGL在我们编程中扮演什么角色
我们可以把OpenGL看成一个大的状态机,里面描述了各种各样的状态。比如:当前画笔的颜色,绘制的区域等,这些都是一些状态。这也就能解释我们为什么在OpenGL要反复用一些函数来设置当前的状态了
OpenGL对象是啥
在OpenGL中,每个对象都有自己的状态和属性,例如位置、旋转、缩放、颜色等。开发者可以使用OpenGL提供的接口来创建、管理和渲染这些对象。
使用对象来记录当前的状态
以下是伪代码,只是疏通思路
Gluint glObjectId = 0; // 提前创建好对象的Id,可以把原理看做指针
glGenObject(1, &glObjectId); // 把id传进去,生成一个对象并且把这个对象与上面id绑定
glBindObject(GL_WINDOW_TRAGET, &glObjectId); // 通过id绑定对象到特定的目标上,并且对象会开始记录
glSetObjectOption(GL_WINDOW_TARGET, GL_OPTION_WINDOW_WIDTH, 600);
glSetObjectOption(GL_WINDOW_TARGET, GL_OPTION_WINDOW_HEIGHT, 800); // 此时glObjectId所对应的对象已经记录了这两个状态
glBindObject(GL_WINDOW_TARGET, 0); // 绑定到id 0,表示结束对象与特定目标的绑定
004. 创建一个简单的画图形的widget,并且了解基本原理
一些注意事项
为什么非要在paintGL()里绘制
因为你最后在其他地方画的图在update()后都会被paintGL画的图覆盖住,没有什么意义
那不能在其他地方调用OpenGL函数吗
可以,但是你要先用makeCurrent()为当前线程设置正确的上下文,否则可能出意外。当然上一点也讲过,这里设置一些画笔什么的就行了,不要画图
开始创建Widget
这里想怎么样都可以,我这里是采用的是一个QMainWindow里套一个QOpenGLWidget,这样使用MenuBar和Action来控制绘图要比直接创建QPushButton快的多。还可以用Qt自带的Designer进行绑定槽,很方便啊。
大概这样子:
我们在MainWindow里添加自己的GLWidget类,并且在MainWindow重设大小的时候给自己的GLWidget重设大小,相当于GLWidget平铺在MainWindow上(注意虽然说是MainWindow但是MainWindow里真正管内容的是一个centralWidget,任何显示内容的都应该以它为父对象)
当然写完这些之后看老师的课发现可以直接把自己的类设置为centralWidget这样不用重写resizeEvent比较方便
所以代码看起来应该是像这样子的:
// MyGLWidget 头文件
#include <QtOpenGLWidgets/QOpenGLWidget>
#include <QOpenGLFunctions>
class MyGLWidget : public QOpenGLWidget, protected QOpenGLFunctions
{
Q_OBJECT
public:
MyGLWidget(QWidget* parent = nullptr);
};
// MyGLWidget 源文件
#include "myglwidget.h"
MyGLWidget::MyGLWidget(QWidget* parent)
: QOpenGLWidget(parent)
{
}
// MainWindow 头文件
#include "myglwidget.h"
#include <QMainWindow>
QT_BEGIN_NAMESPACE
namespace Ui { class MainWindow; }
QT_END_NAMESPACE
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
MainWindow(QWidget *parent = nullptr);
~MainWindow();
protected:
void resizeEvent(QResizeEvent* e) override;
private slots:
void on_actionDraw_Circle_triggered();
void on_actionDraw_Rectangle_triggered();
void on_actionClear_Screen_triggered();
private:
Ui::MainWindow *ui;
MyGLWidget* m_glWidget;
};
// MainWindow 源文件
#include "mainwindow.h"
#include "./ui_mainwindow.h"
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
, ui(new Ui::MainWindow)
{
ui->setupUi(this);
m_glWidget = new MyGLWidget(ui->centralwidget);
m_glWidget->move(0,0);
m_glWidget->show();
}
MainWindow::~MainWindow()
{
delete ui;
}
void MainWindow::resizeEvent(QResizeEvent *e)
{
m_glWidget->resize(ui->centralwidget->size());
}
void MainWindow::on_actionDraw_Circle_triggered()
{
}
void MainWindow::on_actionDraw_Rectangle_triggered()
{
}
void MainWindow::on_actionClear_Screen_triggered()
{
}
此时出来的效果应该是这样的
MenuBar不见是因为我的主题把状态栏弄到上面的任务栏去了
为OpenGLWidget重写基本三个函数
class MyGLWidget : public QOpenGLWidget, protected QOpenGLFunctions
{
Q_OBJECT
public:
MyGLWidget(QWidget* parent = nullptr);
protected:
virtual void initializeGL() {
initializeOpenGLFunctions();
glClearColor(0,0,0,1);
}
virtual void resizeGL(int w, int h) {
}
virtual void paintGL() {
glClear(GL_COLOR_BUFFER_BIT);
}
};
在第一个Demo里已经解释过了,这里不多阐述
至此创建一个使用OpenGL的窗口的过程就结束了
005. 实现自己GLWidget的绘制功能
OpenGL 坐标
归一化坐标(标准化设备坐标),以0,0,0为中心x,y,z都在-1.0f到1.0f的范围内。范围外的会被裁剪
Vertex Array Object
简称VAO,简单的来讲VAO定义了一系列的结构的定义。这是因为在VBO里数据都是用向量的方式储存,我们并不知道某个向量代表的是颜色,位置亦或是其他的属性。所以要用VAO来声明这些结构:比如某一些位置的变量是颜色,另外一些是顶点…
采用offset来锚定每个属性的位置
Vertex Buffer Object
简称VBO,这个里面就是真正存储数据的东西了
使用VAO和VBO画出一个三角形
知道了上面的那些东西之后就可以稍微理解下面代码了
class MyGLWidget : public QOpenGLWidget, protected QOpenGLFunctions_4_5_Core
{
Q_OBJECT
public:
MyGLWidget(QWidget* parent = nullptr);
GLuint VAO, VBO = 0; // 初始化VAO,VBO的id
float vertices[9] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
}; // 创建出三角形的顶点坐标,这里我们看到是一个float型的数组,所以需要才VAO来声明结构,要不opengl根本不知道这几个数字代表着什么
protected:
virtual void initializeGL() {
initializeOpenGLFunctions();
glClearColor(0,0,0,1);
// 创建出VAO和VBO的对象并且将他们的ID赋值给VAO,VBO这两个变量
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
// 绑定VAO和VBO,因为VAO本身就是对结构的声明所以不用指定类型
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
// 为GL_ARRAY_BUFFER创建一个数据缓存区,大小为sizeof(vertices)
// 用来初始化的数据是vertices,缓存方式是GL_STATIC_DRAW(自己搜)
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 这里告知了显卡如何解析我们给他的数据
// 0 表示第0个属性, 3表示每三个GL_FLOAT为一组
// GL_FALSE表示不标准化,3*sizeof(float)表示用这么大的步长
// (void*)0 表示偏移量为0
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
// 启用第0个属性
glEnableVertexAttribArray(0);
// 取消绑定
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
}
virtual void resizeGL(int w, int h) {
}
virtual void paintGL() {
glClear(GL_COLOR_BUFFER_BIT);
glBindVertexArray(VAO);
glColor3f(1,1,1);
// 用Arrays数据进行GL_TRIANGLES的图元方式渲染,从第0个点开始,每3个点组成一个图元
glDrawArrays(GL_TRIANGLES, 0, 3);
}
};
和我们上面的代码结合起来,不出意外您能看到一个三角形被渲染在了屏幕中央
总结
通过上面几个小demo,算是初步认识了OpenGL是啥以及最最基本的工作原理。OpenGL就是一个大的状态机,需要不停地绑定设置一些属性。通过这些最基本的操作,可以将一些数据所对应的顶点渲染成画面。