主要介绍的是Qt中如何使用OpenGL,因为我主要使用OpenGL解决渲染(把解码后的 YUV/RGB 像素数据高效地画到屏幕上),所以这篇文章主要围绕OpenGL的一下几个方面进行介绍,如目录所示,帮助你在Qt中快速入门上手使用OpenGL。
如何你想学习更多关于OpenGL的相关知识,可以从下面这个网站进行学习:https://learnopengl-cn.github.io/
📁 OpenGL
OpenGL是一个由Khronos组织制定并维护的规范。它包含了一系列可以操作图形,图像的函数,严格规定了每个函数该如何执行,以及它们的输出值。至于内部具体每个函数是如何实现的,将由OpenGL库的开发者自行决定。实际的OpenGL库的开发者通常是显卡的生产商。你购买的显卡所支持的OpenGL版本都为这个系列的显卡专门开发的。
📂 核心模式与立即渲染模式
早期的OpenGL使用立即渲染模式(Immediate mode,也就是固定渲染管线),这个模式下绘制图形很方便。OpenGL的大多数功能都被库隐藏起来,开发者很少有控制OpenGL如何进行计算的自由。
从OpenGL3.2开始,规范文档开始废弃立即渲染模式,并鼓励开发者在OpenGL的核心模式。当使用OpenGL的核心模式时,OpenGL迫使我们使用现代的函数。代函数要求使用者真正理解OpenGL和图形编程,它有一些难度,然而提供了更多的灵活性,更高的效率,更重要的是可以更深入的理解图形编程。
📂 状态机
OpenGL本身是一个巨大的状态机:一系列的变量描述OpenGL此时应该如何运行。OpenGL的状态通常称为OpenGL上下文。通常使用如下途径更改OpenGL状态:设置选项,操作缓冲。
通过改变一些上下文变量改变OpenGL状态,从而告诉OpenGL如何去绘图。
通过一些状态设置函数,改变上下文变量改变OpenGL状态。以及状态使用函数,这类函数根据当前OpenGL的状态进行一些操作。
📂 对象
OpenGL库是用C语言写的,同时也支持多种语言的派生,但其内核仍是一个C库。由于C的一些语言结构不易被翻译到其它的高级语言,因此OpenGL开发的时候引入了一些抽象层。"对象(Object)"就是其中一个。
在OpenGL中一个对象是指一些选项的集合,它代表OpenGL状态的一个子集。比如,我们可以用一个对象来代表绘图窗口的设置,之后我们就可以设置它的大小、支持的颜色位数等等。可以把对象看做一个C风格的结构体(Struct):
cpp
struct object_name {
float option1;
int option2;
char[] name;
};
用对象的一个好处是在程序中,我们不止可以定义一个对象,并设置它们的选项,每个对象都可以是不同的设置。在我们执行一个使用OpenGL状态的操作的时候,只需要绑定含有需要的设置的对象即可。
📁 创建窗口
📂 创建窗口
1. 修改CMakeLists.txt,添加OpenGL模块
Qt 6默认使用CMake,在CMakeLists文件中对应的两条语句中添加OpenGLWidgets和Qt6::OpenGLWidgets。

2. 创建OpenGL窗口部件,继承QOpenGLWidget和QOpenGLFunctions
在ui文件中拖拽一个widget控件,提升为我们自己创建的openGLWdiget类。
cpp
#include <QOpenGLWidget>
#include <QOpenGLFunctions>
class OpenGLWidget : public QOpenGLWidget, protected QOpenGLFunctions
{
Q_OBJECT
public:
explicit OpenGLWidget(QWidget *parent = nullptr);
~OpenGLWidget() override;
protected:
// 设置OpenGL资源和状态
void initializeGL() override;
// 渲染OpenGL场景, widget需要更新时调用
void paintGL() override;
// 设置OpenGL视口, 投影等, widget调整大小时调用
void resizeGL(int w, int h) override;
};
3. 核心OpenGL代码
cpp
#include "openglwidget.h"
OpenGLWidget::OpenGLWidget(QWidget *parent)
: QOpenGLWidget(parent)
{
}
OpenGLWidget::~OpenGLWidget()
{
}
void OpenGLWidget::initializeGL()
{
// 初始化OpenGL函数
initializeOpenGLFunctions();
// 设置清屏颜色为深蓝绿色(经典的"你好窗口"背景色)
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
}
void OpenGLWidget::paintGL()
{
// 清除颜色缓冲,使用glClearColor设置的颜色填充
glClear(GL_COLOR_BUFFER_BIT);
}
void OpenGLWidget::resizeGL(int w, int h)
{
// 设置视口(Viewport)为整个窗口
glViewport(0, 0, w, h);
}

📂 创建三角形
图形渲染管线
在OpenGL中,任何事物都是在3D空间中,而屏幕和窗口却是在2D像素数组,这导致OpenGL的大部分工作都是把3D坐标转变为适应屏幕的2D像素。
3D坐标和2D坐标的处理过程是由OpenGL的图形渲染管线管理的:
-
3D坐标转换为2D坐标;
-
把2D坐标转变为实际有颜色的像素。
图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。所有这些阶段都是高度专门化的(它们都有一个特定的函数),并且很容易并行执行。
当今大多数显卡都有成千上万的小处理核心,它们在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理你的数据。这些小程序叫做着色器(Shader)。
下图是一个图形渲染管线的每个阶段的抽象表示,蓝色部分表示我们可以注入自定义的着色器部分。

- 顶点着色器 ------ "给每个点定位"
把3D坐标转为另一种3D坐标,允许对顶点属性进行基本处理。相当于:你有一堆图钉(顶点),你决定每个图钉钉在什么位置。
- 几何着色器(可选) ------ "点不够?给你加点"
把一组顶点作为输入,能通过发出新顶点来生成新的形状。相当于:你画了一个三角形,它帮你复制粘贴变成更多三角形。
- 图元装配 ------ "把点连成形状"
把顶点着色器输出的所有顶点装配成指定图元的形状。相当于:把一堆图钉用线连起来,连成三角形、四边形等形状。
- 光栅化 ------ "把形状变成像素"
把图元映射为最终屏幕上相应的像素,生成供片段着色器使用的片段。你画了一个三角形轮廓,现在要填色------填色前先要看清楚三角形覆盖了哪些小格子。
- 片段着色器 ------ "给每个像素上色"
计算一个像素的最终颜色,是OpenGL高级效果产生的地方。给每个小格子上色------是涂红色、蓝色,还是加上光照效果,都由这里决定。
- Alpha测试与混合 ------ "处理遮挡和透明"
检测片段的深度值来判断像素的前后关系,检查alpha值进行混合。相当于:前面的物体挡住后面的(不透明的),半透明的物体和后面的颜色叠在一起显示。
顶点输入
OpenGL是一个3D图形库,所有指定的坐标都是3D坐标(x,y,z)。OpenGL不是简单的把所有3D坐标变换为屏幕上的2D像素,OpenGL仅当3D坐标在3个轴上的(x,y,z)上-1.0到1.0的范围内时才处理它。所以这个范围内的坐标叫做标准化设备坐标。此范围内的坐标最终显示在屏幕上。
例如希望渲染一个三角形,我们一共要指定三个顶点,每个顶点都有一个3D位置。我们会将它们以标准化设备坐标的形式(OpenGL的可见区域)定义为一个float数组。
由于我们渲染的是一个2D三角形,所以它顶点的z坐标设置为0.0。
cpp
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
标准化设备坐标是一个x、y和z值在-1.0到1.0的一小段空间。任何落在范围外的坐标都会被丢弃/裁剪,不会显示在你的屏幕上。下图是标准化设备坐标中的三角形(忽略z轴):

顶点缓冲对象 VBO
定义这样的顶点数据以后,我们会把它作为输入发送给图形渲染管线的第一个处理阶段:顶点着色器。它会在GPU上创建内存用于储存我们的顶点数据,还要配置OpenGL如何解释这些内存,并且指定其如何发送给显卡。顶点着色器接着会处理我们在内存中指定数量的顶点。
通过顶点缓冲对象 VBO管理这个内存,VBO会在GPU内存中存储大量顶点。使用这些缓冲对象的好处是我们可以一次性的发送一大批数据到显卡上,而不是每个顶点发送一次。
cpp
unsigned int VBO;
glGenBuffers(1, &VBO);
OpenGL中对象通过唯一的缓冲ID进行管理。OpenGL有很多缓冲对象类型,顶点缓冲对象的缓冲类型是GL_ARRAY_BUFFER。OpenGL允许我们同时绑定多个缓冲,只要它们是不同的缓冲类型。
cpp
glBindBuffer(GL_ARRAY_BUFFER, VBO);
从这一刻起,我们使用的任何(在GL_ARRAY_BUFFER目标上的)缓冲调用都会用来配置当前绑定的缓冲(VBO)。然后我们可以调用glBufferData函数,它会把之前定义的顶点数据复制到缓冲的内存中:
cpp
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
第四个参数指定了我们希望显卡如何管理给定的数据。它有三种形式:
GL_STATIC_DRAW :数据不会或几乎不会改变。
GL_DYNAMIC_DRAW:数据会被改变很多。
GL_STREAM_DRAW :数据每次绘制时都会改变。
总结:
VBO 就是显卡里的一块"数据仓库",专门用来存放模型的顶点数据(位置、颜色、纹理坐标等)。解决CPU与显卡之间传数据慢的问题,数据存在显卡身边(显存),不需要每次画都从 CPU 传一次,速度飞起。
用VBO的步骤:
① 创建VBO glGenBuffers(1, &VBO) 在显卡里申请一个"仓库编号"
② 绑定VBO glBindBuffer(GL_ARRAY_BUFFER, VBO) 告诉显卡"我要用这个仓库"
③ 上传数据 glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW) 把顶点数据一次性传到显存里
④ 画的时候 glDrawArrays(GL_TRIANGLES, 0, 3) 直接用仓库里的数据画,不用再传了
顶点数组对象 VAO
VBO是存放顶点数据的"仓库",那么VAO是记录仓库里数据怎么摆放的"说明书"。
顶点数组对象可以像顶点缓冲对象那样被绑定,任何随后的顶点属性调用都会存储在这个VAO中。
这样的好处就是,当配置顶点属性指针时,你只需要将那些调用执行一次,之后再绘制物体的时候只需要绑定相应的VAO就行了。这使在不同顶点数据和属性配置之间切换变得非常简单,只需要绑定不同的VAO就行了。刚刚设置的所有状态都将存储在VAO中
一个顶点数组对象会储存以下这些内容:
- glEnableVertexAttribArray和glDisableVertexAttribArray的调用。
- 通过glVertexAttribPointer设置的顶点属性配置。
- 通过glVertexAttribPointer调用与顶点属性关联的顶点缓冲对象。

cpp
VBO 就是显卡里的一排货架,上面摆满了数据:
货架VBO-1:位置数据 [A位置][B位置][C位置]...
货架VBO-2:颜色数据 [A红色][B绿色][C蓝色]...
货架VBO-3:纹理坐标 [A(0,0)][B(1,0)][C(0,1)]...
VAO 就是一张说明书,告诉显卡怎么从货架上取数据:
VAO 说明书上写着:
┌─────────────────────────────────────┐
│ 位置数据 → 去 VBO-1 取 │
│ → 每3个float为一个顶点 │
│ → 从第0个字节开始 │
│ → 把这个数据送给 location=0 │
├─────────────────────────────────────┤
│ 颜色数据 → 去 VBO-2 取 │
│ → 每3个float为一个顶点 │
│ → 从第0个字节开始 │
│ → 把这个数据送给 location=1 │
└─────────────────────────────────────┘
顶点着色器
顶点着色器(Vertex Shader)是几个可编程着色器中的一个。如果我们打算做渲染的话,现代OpenGL需要我们至少设置一个顶点和一个片段着色器。
处理每一个顶点。模型由成千上万个顶点组成,每个顶点都会经过顶点着色器处理一次。
具体做三件事:
① 坐标变换 把顶点的位置从"模型自己的坐标"变成"屏幕上的坐标" 就像你手里拿着一张地图,把地图上的点对应到实际街道的位置。
② 传递数据 把每个顶点的颜色、纹理坐标等信息传递给后面的阶段 给每个图钉贴上标签,写上"我是红色"、"我的纹理在(0.5, 0.5)"。
③ 逐顶点计算 可以做光照、位移、形变等效果 让每个点动起来(比如旗帜飘动)。
用着色器语言GLSL(OpenGL Shading Language)编写顶点着色器,然后编译这个着色器,这样我们就可以在程序中使用它了。
GLSL的基本语法规则:
版本声明,必须写在第一行
变量类型:
float 浮点数 1.0
int 整数 42
bool 布尔 true / false
vec2 二维向量 vec2(1.0, 0.0)
vec3 三维向量 vec3(1.0, 0.0, 0.0)
vec4 四维向量 vec4(1.0, 0.0, 0.0, 1.0)
mat3 3×3 矩阵 mat3(1.0)
mat4 4×4 矩阵 mat4(1.0)
sampler2D 2D纹理 用于纹理取样
- 向量访问方式:
vec4 color = vec4(1.0, 0.5, 0.2, 1.0);
color.x // 1.0 ← 第一种:xyzw
color.y // 0.5
color.z // 0.2
color.w // 1.0
color.r // 1.0 ← 第二种:rgba(颜色场景常用)
color.g // 0.5
color.b // 0.2
color.a // 1.0
color.s // 1.0 ← 第三种:stpq(纹理坐标常用)
color.t // 0.5
// 还可以重组(swizzle)
color.rgb // 取前三个分量 → vec3(1.0, 0.5, 0.2)
color.xy // 取前两个分量 → vec2(1.0, 0.5)
vec4(color.xyz, 0.5) // 重新组合
- 三种关键关键字
(1). in 输入,从上一个阶段传进来
(2). out 输出,传给下一个阶段
(3). uniform,全局变量,从CPU传入,所有顶点/片段共享
cpp
#version 330 core
layout(location = 0) in vec3 aPos; // layout(location = 0) 表示这个输入变量的编号为0
layout(location = 1) in vec3 aColor; // layout(location = 1) 表示这个输入变量的编号为1
out vec3 vColor;
void main() {
// gl_Position OpenGL 的内置变量,代表顶点最终的屏幕位置
/*
vec4(aPos, 1.0) 把 (x, y, z) 变成 (x, y, z, 1.0) --- 齐次坐标
3D图形学用四维向量(x, y, z, w)表示位置, w=1.0表示这是一个点, 不是方向.
*/
gl_Position = vec4(aPos, 1.0);
// 把从CPU接收到的颜色aColor, 直接传递给片段着色器的vColor
vColor = aColor;
}
执行过程可视化:
cpp
假设你画了一个三角形,三个顶点的数据如下:
顶点A:位置 (0.0, 0.5, 0.0) 颜色 (1.0, 0.0, 0.0) ← 红色
顶点B:位置 (0.5, -0.5, 0.0) 颜色 (0.0, 1.0, 0.0) ← 绿色
顶点C:位置 (-0.5, -0.5, 0.0) 颜色 (0.0, 0.0, 1.0) ← 蓝色
顶点着色器会运行 3 次(因为有 3 个顶点):
第1次(顶点A):
aPos = (0.0, 0.5, 0.0) aColor = (1.0, 0.0, 0.0)
gl_Position = (0.0, 0.5, 0.0, 1.0)
vColor = (1.0, 0.0, 0.0) → 传给片段着色器
第2次(顶点B):
aPos = (0.5, -0.5, 0.0) aColor = (0.0, 1.0, 0.0)
gl_Position = (0.5, -0.5, 0.0, 1.0)
vColor = (0.0, 1.0, 0.0) → 传给片段着色器
第3次(顶点C):
aPos = (-0.5, -0.5, 0.0) aColor = (0.0, 0.0, 1.0)
gl_Position = (-0.5, -0.5, 0.0, 1.0)
vColor = (0.0, 0.0, 1.0) → 传给片段着色器
片段着色器
当在OpenGL或GLSL中定义一个颜色的时候,我们把颜色每个分量的强度设置在0.0到1.0之间。这三种颜色分量的不同调配可以生成超过1600万种不同的颜色。
cpp
#version 330 core
in vec3 vColor;
out vec4 FragColor;
void main() {
FragColor = vec4(vColor, 1.0);
}
编译着色器
为了能够让OpenGL使用着色器,我们必须在运行时动态编译它的源代码。我们首先要做的是创建一个着色器对象,注意还是用ID来引用的。
在Qt中直接帮我们封装好了,我们只需要使用QOpenGLShaderProgram即可。
cpp
// 编译连接着色器
program = new QOpenGLShaderProgram(this);
program->addShaderFromSourceFile(QOpenGLShader::Vertex, ":/shaders/vertexShader.vert");
program->addShaderFromSourceFile(QOpenGLShader::Fragment, ":/shaders/fragmentShader.frag");
if(!program->link())
{
qDebug() << "program link error";
return ;
}
链接顶点属性
顶点着色器允许我们指定任何以顶点属性为形式的输入,具有很强的灵活性,也意味着我们必须手动指定输入数据的哪一个部分对应顶点着色器的哪一个顶点属性。
因此,我们必须在渲染前指定OpenGL该如何解释顶点数据。
- 位置数据被储存为32位(4字节)浮点值。
- 每个位置包含3个这样的值。
- 在这3个值之间没有空隙(或其他值)。这几个值在数组中紧密排列(Tightly Packed)。
- 数据中第一个值在缓冲开始的位置。
当然Qt也已经封装好了
cpp
// setAttributeBuffer配置顶点属性指针, 告诉GPU如何从VBO中读取顶点数据
// 属性索引, 数据类型, 偏移量, 分量个数, 步长
// enableAttributeArray打开属性的开关, 不调用即使配置好了数据元, GPU也不会使用这些属性
program->setAttributeBuffer(0, GL_FLOAT, 0, 3, 6 * sizeof(GLfloat));
program->enableAttributeArray(0);
program->setAttributeBuffer(1, GL_FLOAT, 3 * sizeof(GLfloat), 3, 6 * sizeof(GLfloat));
program->enableAttributeArray(1);
完整代码
#ifndef OPENGLWIDGET_H
#define OPENGLWIDGET_H
#include <QOpenGLWidget>
#include <QOpenGLFunctions_3_3_Core>
#include <QOpenGLShaderProgram>
#include <QOpenGLVertexArrayObject>
#include <QOpenGLBuffer>
class OpenGLWidget : public QOpenGLWidget, protected QOpenGLFunctions_3_3_Core
{
Q_OBJECT
public:
explicit OpenGLWidget(QWidget *parent = nullptr);
~OpenGLWidget() override;
protected:
// 设置OpenGL资源和状态
void initializeGL() override;
// 渲染OpenGL场景, widget需要更新时调用
void paintGL() override;
// 设置OpenGL视口, 投影等, widget调整大小时调用
void resizeGL(int w, int h) override;
private:
// 着色器
QOpenGLShaderProgram* program = nullptr;
// VAO
QOpenGLVertexArrayObject vao;
// VBO
QOpenGLBuffer vbo;
};
#endif // OPENGLWIDGET_H
cpp
#include "openglwidget.h"
OpenGLWidget::OpenGLWidget(QWidget *parent)
: QOpenGLWidget(parent)
{
}
OpenGLWidget::~OpenGLWidget()
{
vao.destroy();
vbo.destroy();
}
void OpenGLWidget::initializeGL()
{
// 1. 初始化当前 OpenGL 上下文中所有的函数指针。
initializeOpenGLFunctions();
// 2. 编译链接着色器
program = new QOpenGLShaderProgram(this);
// 从文件中加载顶点/片段着色器源码并编译
program->addShaderFromSourceFile(QOpenGLShader::Vertex, ":/shaders/vertexShader.vert");
program->addShaderFromSourceFile(QOpenGLShader::Fragment, ":/shaders/fragmentShader.frag");
if(!program->link())
{
qDebug() << "program link error";
return ;
}
// 3. 创建并绑定这个VAO, 之后的VBO和属性配置都会被这个VAO记录
vao.create();
vao.bind();
// 4. 定义顶点数据并上传到 VBO
GLfloat vertices[] = {
0.0, 0.5, 0.0, 1.0, 0.0, 0.0, // 第一个顶点
-0.5, -0.5, 0.0, 0.0, 1.0, 0.0, // 第二个顶点
0.5, -0.5, 0.0, 0.0, 0.0, 1.0, // 第三个顶点
};
vbo.create();
vbo.bind();
vbo.allocate(vertices, sizeof(vertices));
// 5. 配置顶点属性指针
// setAttributeBuffer配置顶点属性指针, 告诉GPU如何从VBO中读取顶点数据
// 属性索引, 数据类型, 偏移量, 分量个数, 步长
// enableAttributeArray打开属性的开关, 不调用即使配置好了数据元, GPU也不会使用这些属性
program->setAttributeBuffer(0, GL_FLOAT, 0, 3, 6 * sizeof(GLfloat));
program->enableAttributeArray(0);
program->setAttributeBuffer(1, GL_FLOAT, 3 * sizeof(GLfloat), 3, 6 * sizeof(GLfloat));
program->enableAttributeArray(1);
// 6. 解绑收尾
vao.release();
vbo.release();
program->release();
}
void OpenGLWidget::paintGL()
{
// 绘制背景色
glClearColor(0.2, 0.3, 0.3, 1.0);
glClear(GL_COLOR_BUFFER_BIT);
// 绑定状态机
program->bind();
vao.bind();
vbo.bind();
// 绘制图像
glDrawArrays(GL_TRIANGLES, 0, 3);
// 释放状态机
vao.release();
vbo.release();
program->release();
}
void OpenGLWidget::resizeGL(int w, int h)
{
// 设置视口(Viewport)为整个窗口
glViewport(0, 0, w, h);
}
📁 着色器
着色器是使用一种叫做GLSL的类C语言写成的。GLSL是为了图形计算量身定制的。包含了一些针对向量和矩阵操作的有用特性。
着色器的开头总是要声明版本,接着是输入和输出变量,uniform和main函数。每个着色器的入口点都是main函数,这个函数中我们处理所有的输入变量,并将结果输出到输出变量中。
cpp
一个典型的着色器有下面的结构:
#version version_number
in type in_variable_name;
in type in_variable_name;
out type out_variable_name;
uniform type uniform_name;
void main()
{
// 处理输入并进行一些图形操作
...
// 输出处理过的结果到输出变量
out_variable_name = weird_stuff_we_processed;
}
📂 数据类型
GLSL包含了C等其他语言大部分的默认基础数据类型:int,float,double,unit和bool。GLSL也有两种容器类型:向量和矩阵。
向量
|---------|-------------------------|
| vecn | 包含n个float分量的默认向量 |
| bvecn | 包含n个bool分量的向量 |
| ivecn | 包含n个int分量的向量 |
| uvecn | 包含n个unsigned int分量的向量 |
| dvecn | 包含n个double分量的向量 |
一个向量的分量可以通过vec.x这种方式获取,这里x是指这个向量的第一个分量。你可以分别使用.x、.y、.z和.w来获取它们的第1、2、3、4个分量。GLSL也允许你对颜色使用rgba,或是对纹理坐标使用stpq访问相同的分量。
📂 输入输出
每个着色器都有输入和输出,这样才能进行数据交流和传递。
GLSL定义了in和out关键字专门来实现这个目的。每个着色器使用这两个关键字设定输入和输出,只要一个输出变量与下一个着色器阶段的输入匹配,它就会传递下去。但在顶点和片段着色器中会有点不同。
顶点着色器应该接收的是一种特殊形式的输入,否则就会效率低下。顶点着色器的输入特殊在,它从顶点数据中直接接收输入。
为了定义顶点数据该如何管理,我们使用location这一元数据指定输入变量,这样我们才可以在CPU上配置顶点属性。顶点着色器需要为它的输入提供一个额外的layout标识,这样我们才能把它链接到顶点数据。
另一个例外是片段着色器,它需要一个vec4颜色输出变量,因为片段着色器需要生成一个最终输出的颜色。如果你在片段着色器没有定义输出颜色,OpenGL会把你的物体渲染为黑色(或白色)。
📂 Uniform
Uniform是另一种从我们的应用程序在 CPU 上传递数据到 GPU 上的着色器的方式,但uniform和顶点属性有些不同。
首先,uniform是全局的(Global)。全局意味着uniform变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。
第二,无论你把uniform值设置成什么,uniform会一直保存它们的数据,直到它们被重置或更新。
📁 纹理
如果想让图形看起来更真实,我们就必须有足够多的顶点,从而指定足够多的颜色。这将会产生很多额外开销,因为每个模型都会需求更多的顶点,每个顶点又需求一个颜色属性。
艺术家和程序员更喜欢使用纹理(Texture)。纹理是一个2D图片(甚至也有1D和3D的纹理),它可以用来添加物体的细节;
纹理本质上就是一张图片(或内存中的一块数据)。例如一张纹理图片是512x512 像素,每一个像素就是一个小格子,也叫作纹素 (Texel),就是一个纹理上的一个颜色点。
cpp
纹理在渲染管线中的位置:
顶点着色器 片段着色器
处理顶点位置 给每个像素上色
│ │
│ 传递纹理坐标 (u, v) │ 从纹理图片上取颜色
▼ ▼
顶点数据 ──→ 光栅化 ──→ 片段着色器 ──→ 屏幕
│
│ 纹理取样:
│ texture(纹理, uv坐标)
│ → 返回对应位置的颜色
▼
最终像素颜色
📂 纹理环绕方式
纹理用的是 UV 坐标(也叫纹理坐标):
cpp
纹理图片的坐标系统:
(0,1) ───────── (1,1)
│ │
│ (u, v) │
│ 每个顶点 │
│ 对应一个 │
│ 纹理坐标 │
│ │
(0,0) ───────── (1,0)
u = 横向位置 (0.0 ~ 1.0)
v = 纵向位置 (0.0 ~ 1.0)
纹理坐标的范围通常是从(0, 0)到(1, 1),那如果我们把纹理坐标设置在范围之外会发生什么?OpenGL默认的行为是重复这个纹理图像(我们基本上忽略浮点纹理坐标的整数部分),但OpenGL提供了更多的选择:
| 环绕方式 | 描述 |
|---|---|
| GL_REPEAT | 对纹理的默认行为。重复纹理图像。 |
| GL_MIRRORED_REPEAT | 和GL_REPEAT一样,但每次重复图片是镜像放置的。 |
| GL_CLAMP_TO_EDGE | 纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。 |
| GL_CLAMP_TO_BORDER | 超出的坐标为用户指定的边缘颜色。 |

📂 纹理过滤
OpenGL需要知道怎样将纹理像素(Texture Pixel,也叫Texel,译注1)映射到纹理坐标。当你有一个很大的物体但是纹理的分辨率很低的时候这就变得很重要了。
OpenGL也有对于纹理过滤(Texture Filtering)的选项。纹理过滤有很多个选项,但是现在我们只讨论最重要的两种:GL_NEAREST和GL_LINEAR。
GL_NEAREST(也叫邻近过滤,Nearest Neighbor Filtering)是OpenGL默认的纹理过滤方式。当设置为GL_NEAREST的时候,OpenGL会选择中心点最接近纹理坐标的那个像素。下图中你可以看到四个像素,加号代表纹理坐标。左上角那个纹理像素的中心距离纹理坐标最近,所以它会被选择为样本颜色:

GL_LINEAR(也叫线性过滤,(Bi)linear Filtering)它会基于纹理坐标附近的纹理像素,计算出一个插值,近似出这些纹理像素之间的颜色。一个纹理像素的中心距离纹理坐标越近,那么这个纹理像素的颜色对最终的样本颜色的贡献越大。下图中你可以看到返回的颜色是邻近像素的混合色:

GL_NEAREST产生了颗粒状的图案,我们能够清晰看到组成纹理的像素,而GL_LINEAR能够产生更平滑的图案,很难看出单个的纹理像素。GL_LINEAR可以产生更真实的输出,但有些开发者更喜欢8-bit风格,所以他们会用GL_NEAREST选项。
📂 使用纹理
cpp
#ifndef OPENGLWIDGET_H
#define OPENGLWIDGET_H
#include <QOpenGLWidget>
#include <QOpenGLFunctions_3_3_Core>
#include <QOpenGLShaderProgram>
#include <QOpenGLVertexArrayObject>
#include <QOpenGLBuffer>
#include <QImage>
class OpenGLWidget : public QOpenGLWidget, protected QOpenGLFunctions_3_3_Core
{
Q_OBJECT
public:
explicit OpenGLWidget(QWidget *parent = nullptr);
~OpenGLWidget() override;
protected:
// 设置OpenGL资源和状态
void initializeGL() override;
// 渲染OpenGL场景, widget需要更新时调用
void paintGL() override;
// 设置OpenGL视口, 投影等, widget调整大小时调用
void resizeGL(int w, int h) override;
private:
// 着色器
QOpenGLShaderProgram* program = nullptr;
// VAO
QOpenGLVertexArrayObject vao;
// VBO
QOpenGLBuffer vbo;
// 纹理ID
GLuint textureId = 0;
GLuint textureId2 = 0;
};
#endif // OPENGLWIDGET_H
cpp
#include "openglwidget.h"
OpenGLWidget::OpenGLWidget(QWidget *parent)
: QOpenGLWidget(parent)
{
}
OpenGLWidget::~OpenGLWidget()
{
// 释放纹理
if(textureId) glDeleteTextures(1, &textureId);
if(textureId2) glDeleteTextures(1, &textureId2);
// 释放 VAO / VBO
vao.destroy();
vbo.destroy();
}
void OpenGLWidget::initializeGL()
{
// 1. 初始化当前 OpenGL 上下文中所有的函数指针。
initializeOpenGLFunctions();
// 2. 编译链接着色器
program = new QOpenGLShaderProgram(this);
// 从文件中加载顶点/片段着色器源码并编译
program->addShaderFromSourceFile(QOpenGLShader::Vertex, ":/shaders/vertexShader.vert");
program->addShaderFromSourceFile(QOpenGLShader::Fragment, ":/shaders/fragmentShader.frag");
if(!program->link())
{
qDebug() << "program link error";
return ;
}
// 3. 定义顶点数据并上传到 VBO
// 顶点数据:位置(xyz) + 颜色(rgb) + 纹理坐标(uv)
GLfloat vertices[] = {
// 位置 颜色 纹理坐标
0.0, 0.5, 0.0, 1.0, 1.0, 1.0, 0.5, 1.0, // 顶点0:顶
-0.5, -0.5, 0.0, 1.0, 1.0, 1.0, 0.0, 0.0, // 顶点1:左下
0.5, -0.5, 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, // 顶点2:右下
};
vbo.create();
vbo.bind();
vbo.allocate(vertices, sizeof(vertices));
// 4. 创建并绑定这个VAO, 之后的VBO属性配置都会被这个VAO记录
vao.create();
vao.bind();
// 5. 配置顶点属性指针
program->setAttributeBuffer(0, GL_FLOAT, 0, 3, 8 * sizeof(GLfloat));
program->enableAttributeArray(0);
program->setAttributeBuffer(1, GL_FLOAT, 3 * sizeof(GLfloat), 3, 8 * sizeof(GLfloat));
program->enableAttributeArray(1);
program->setAttributeBuffer(2, GL_FLOAT, 6 * sizeof(GLfloat), 2, 8 * sizeof(GLfloat));
program->enableAttributeArray(2);
// 6. 加载纹理
QImage img(":1.jpg");
if(img.isNull()) {
qDebug() << "texture load failed";
} else {
img = img.convertToFormat(QImage::Format_RGBA8888).mirrored();
// 生成纹理对象
glGenTextures(1, &textureId);
// 绑定纹理
glBindTexture(GL_TEXTURE_2D, textureId);
// 上传图像数据到 GPU
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, img.width(), img.height(), 0, GL_RGBA, GL_UNSIGNED_BYTE, img.bits());
// 设置缩小过滤器
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
// 设置放大过滤器
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// 设置 S 方向(水平)的包裹模式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
// 设置 T 方向(垂直)的包裹模式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// 解绑纹理
glBindTexture(GL_TEXTURE_2D, 0);
}
QImage img2(":/2.jpg");
if(img2.isNull()) {
qDebug() << "texture load failed";
} else {
img2 = img2.convertToFormat(QImage::Format_RGBA8888).mirrored();
// 生成纹理对象
glGenTextures(1, &textureId2);
// 绑定纹理
glBindTexture(GL_TEXTURE_2D, textureId2);
// 上传图像数据: 多级渐远纹理级别 GPU内部的存储格式 图像宽度 图像高度 历史遗留参数 CPU端数据的排列格式 每个分量的数据类型 指向像素数据
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, img2.width(), img2.height(), 0, GL_RGBA, GL_UNSIGNED_BYTE, img2.bits());
// 设置缩小过滤器: GL_LINEAR取周围4个像素的加权平均->平滑; GL_NEAREST取最近的一个像素->锐利/锯齿
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
// 设置放大过滤器
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// 设置S方向的包裹模式: GL_CLAMP_TO_EDGE 超出[0, 1]范围时, 取边缘像素
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
// 设置T方向的包裹模式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// 解绑纹理
glBindTexture(GL_TEXTURE_2D, 0);
}
// 7. 解绑
vao.release();
vbo.release();
program->release();
}
void OpenGLWidget::paintGL()
{
// 绘制背景色
glClear(GL_COLOR_BUFFER_BIT);
glClearColor(0.2, 0.3, 0.3, 1.0);
// 绑定状态机
program->bind();
vao.bind();
// 绑定纹理到纹理单元0
// 选中纹理单元 0
glActiveTexture(GL_TEXTURE0);
// 把纹理绑到该单元
glBindTexture(GL_TEXTURE_2D, textureId);
// 告诉着色器用单元 0: uTexture是片段着色器中sampler2D变量的名字, 告诉他去纹理段元0取数据
program->setUniformValue("uTexture", 0);
// 绑定纹理到纹理单元1
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, textureId2);
program->setUniformValue("uTexture2", 1);
// 绘制图像
glDrawArrays(GL_TRIANGLES, 0, 3);
// 释放状态机
vao.release();
program->release();
}
void OpenGLWidget::resizeGL(int w, int h)
{
// 设置视口(Viewport)为整个窗口
glViewport(0, 0, w, h);
}
cpp
#version 330 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aColor;
layout(location = 2) in vec2 aTexCoord;
out vec3 vColor;
out vec2 vTexCoord;
void main()
{
gl_Position = vec4(aPos, 1.0);
vColor = aColor;
vTexCoord = aTexCoord;
}
cpp
#version 330 core
in vec3 vColor;
in vec2 vTexCoord;
out vec4 fColor;
uniform sampler2D uTexture; // 纹理 1 (绑定到 GL_TEXTURE0)
uniform sampler2D uTexture2; // 纹理 2 (绑定到 GL_TEXTURE1)
void main()
{
vec4 color1 = texture(uTexture, vTexCoord);
vec4 color2 = texture(uTexture2, vTexCoord);
// 混合方式任选一种:
// 方式 A:各 50% 混合
fColor = mix(color1, color2, 0.5) * vec4(vColor, 1.0);
// 方式 B:只显示纹理 2(覆盖)
// fColor = color2 * vec4(vColor, 1.0);
// 方式 C:纹理 1 × 纹理 2(相乘叠加)
// fColor = color1 * color2 * vec4(vColor, 1.0);
// 方式 D:上下/左右分屏(根据纹理坐标)
// if(vTexCoord.x < 0.5)
// fColor = color1 * vec4(vColor, 1.0);
// else
// fColor = color2 * vec4(vColor, 1.0);
}