简介
在学习完Games101,以及手撸一个软光栅之后,来学习一下OpenGL~
OpenGL常被视作提供图形操作函数的API,但其本质是由Khronos制定的规范(Specification),仅严格定义函数行为及输出标准,具体实现(如底层优化、硬件适配)由开发者(如显卡厂商)自行完成。
核心模式与立即渲染模式
OpenGL早期采用立即渲染模式(固定管线),简化了图形绘制但效率低且控制受限。随着版本迭代,OpenGL 3.2起废弃此模式,转向核心模式,强制使用现代函数并移除旧特性。核心模式虽需深入理解图形编程(如手动管理渲染流程),但显著提升了灵活性与性能,同时迫使开发者掌握底层细节,牺牲易用性换取更高效的硬件控制能力。
状态机
OpenGL自身是一个巨大的状态机(State Machine):一系列的变量描述OpenGL此刻应当如何运行。OpenGL的状态通常被称为OpenGL上下文(Context)。我们通常使用如下途径去更改OpenGL状态:设置选项,操作缓冲。最后,我们使用当前OpenGL上下文来渲染。下面是一个例子
|
|
当使用OpenGL的时候,我们会遇到一些状态设置函数(State-changing Function),这类函数将会改变上下文。以及状态使用函数(State-using Function),这类函数会根据当前OpenGL的状态执行一些操作。只要你记住OpenGL本质上是个大状态机,就能更容易理解它的大部分特性。
对象
在OpenGL中一个对象是指一些选项的集合,它代表OpenGL状态的一个子集。这块的解释还看不明白,等学习后再补充
GLFW和GLAD
GLFW是一个专门针对OpenGL的C语言库,它提供了一些渲染物体所需的最低限度的接口。它允许用户创建OpenGL上下文、定义窗口参数以及处理用户输入,对我们来说这就够了。如果没有 GLFW 这样的库,开发者需要自己针对不同的操作系统编写复杂的代码来创建窗口和处理用户输入,这将大大增加开发的难度和工作量。使用 GLFW 可以简化这些底层的操作,让开发者能够更加专注于 OpenGL 的图形渲染部分,提高开发效率
由于 OpenGL 驱动版本众多,不同的显卡厂商和操作系统对 OpenGL 函数的实现可能会有所不同,而且 OpenGL 中的大多数函数的位置在编译时无法确定,需要在运行时查询。GLAD 的主要作用就是管理 OpenGL 的函数指针,它会根据当前的系统和显卡环境,动态地加载正确的 OpenGL 函数地址,并将这些函数地址保存在函数指针中,以便开发者在程序运行时能够正确地调用 OpenGL 函数。这样可以确保程序在不同的硬件和操作系统环境下都能正常运行,提高了程序的兼容性。
至于GLFW和glad的安装这里不会提及
Hello OpenGL
⚠️两个头文件的导入顺序。GLAD的头文件包含了正确的OpenGL头文件(例如GL/gl.h),所以需要在其它依赖于OpenGL的头文件之前包含GLAD。
|
|
进一步利用GLFW进行窗口创建,并将OpenGL上下文设置到这个窗口上
|
|
下一步就是用glad加载函数指针
|
|
紧接着需要提供渲染的窗口大小,这个就是进行视口变换时的依据
|
|
下面介绍回调函数
|
|
最终就是渲染循环了,毕竟不只是渲染一幅图片
|
|
双缓冲(Double Buffer) 应用程序使用单缓冲绘图时可能会存在图像闪烁的问题。 这是因为生成的图像不是一下子被绘制出来的,而是按照从左到右,由上而下逐像素地绘制而成的。最终图像不是在瞬间显示给用户,而是通过一步一步生成的,这会导致渲染的结果很不真实。为了规避这些问题,我们应用双缓冲渲染窗口应用程序。前缓冲保存着最终输出的图像,它会在屏幕上显示;而所有的的渲染指令都会在后缓冲上绘制。当所有的渲染指令执行完毕后,我们交换(Swap)前缓冲和后缓冲,这样图像就立即呈显出来,之前提到的不真实感就消除了。
最后一件事,窗口关闭的处理
|
|
渲染操作都是写在while循环中的,现在简单写一下清屏操作,并通过glClearColor设置清屏后显示的颜色
|
|
当前的完整代码如下
|
|
运行结果如下图

Triangle 三角形
首先了解三个概念 顶点数组对象:Vertex Array Object,VAO 顶点缓冲对象:Vertex Buffer Object,VBO 元素缓冲对象:Element Buffer Object,EBO 或 索引缓冲对象 Index Buffer Object,IBO
顶点缓冲对象 VBO
OpenGL不是简单地把所有的3D坐标变换为屏幕上的2D像素;OpenGL仅当3D坐标在3个轴(x、y和z)上-1.0到1.0的范围内时才处理它。所有在这个范围内的坐标叫做标准化设备坐标(Normalized Device Coordinates),此范围内的坐标最终显示在屏幕上(在这个范围以外的坐标则不会显示)。 这里为了简单,没有设置深度
|
|
从此刻起,我们对GL_ARRAY_BUFFER上的操作都属于对当前绑定的VBO进行操作。 glBufferData是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数。第一个参数是目标缓冲的类型:顶点缓冲对象当前绑定到GL_ARRAY_BUFFER目标上。第二个参数指定传输数据的大小(以字节为单位);用一个简单的sizeof计算出顶点数据大小就行。第三个参数是我们希望发送的实际数据。至于第四个参数有三种情况:
- GL_STATIC_DRAW :数据不会或几乎不会改变。
- GL_DYNAMIC_DRAW:数据会被改变很多。
- GL_STREAM_DRAW :数据每次绘制时都会改变。
|
|
现在我们已经把顶点数据储存在显卡的内存中,用VBO这个顶点缓冲对象管理。下面我们会创建一个顶点着色器和片段着色器来真正处理这些数据。现在我们开始着手创建它们吧。
先简单来进行硬编码写一个简单的顶点着色器
|
|
为了运行glsl,需要在运行时动态编译源代码。需要先创建着色器对象,并编译
|
|
如果想检查运行编译是否正确,以及报错信息也是比较麻烦
|
|
片段着色器操作类似,主要处理光栅化后每个像素的着色
|
|
最终还需要链接成一个程序
|
|
检查报错信息
|
|
最后还需要use这个程序
|
|
顶点数组对象 VAO
顶点着色器允许我们指定任何以顶点属性为形式的输入。这使其具有很强的灵活性的同时,它还的确意味着我们必须手动指定输入数据的哪一个部分对应顶点着色器的哪一个顶点属性。所以,我们必须在渲染前指定OpenGL该如何解释顶点数据。顶点数据的解析方式如下
我们需要告诉OpenGL如何解析顶点数据,比如紧密的字节序列的一段代表一个顶点的数据,但数据中可能不止顶点的位置,可能还有顶点的颜色之类的,需要告诉OpenGL一个顶点数据有多大,顶点位置的偏移量是多少(因为不一定一开始就是位置信息)
|
|
上边代码中的0就是之前写顶点着色器代码中的(location=0)
如果把这些状态配置存储在一个对象中,通过绑定他来启动该状态,这就是VAO。
顶点数组对象(Vertex Array Object, VAO)可以像顶点缓冲对象那样被绑定。VAO中存储的内容如下
VAO创建过程与VBO类似
|
|
当创建并绑定好VAO和VBO后就可以进行渲染了 ⚠️ 渲染函数要写到While循环里
|
|
元素缓冲对象 EBO/ 索引缓冲对象 IEO
其实就是保存顶点的顺序,比如如果VAO中是这样的数据
|
|
我们指定了右下角和左上角两次!一个矩形只有4个而不是6个顶点,这样就产生50%的额外开销。EBO是一个缓冲区,就像一个顶点缓冲区对象一样,它存储 OpenGL 用来决定要绘制哪些顶点的索引。
|
|
EBO创建和绑定方法与VBO基本一致
|
|
glDrawElements绘制的时候就不是直接绘制了,而是使用EBO获取索引来绘制
|
|
**另外可以使用VAO中的数据来保存EBO。在绑定VAO时,绑定的最后一个元素缓冲区对象存储为VAO的元素缓冲区对象。然后,绑定到VAO也会自动绑定该EBO。**这步操作是自动进行的,但需要先绑定VAO。 如果想绘制线框模型可以用以下代码来决定是线框模型还是填充模型
|
|

着色器
在Hello Triangle教程中提到,着色器(Shader)是运行在GPU上的小程序。这些小程序为图形渲染管线的某个特定部分而运行。从基本意义上来说,着色器只是一种把输入转化为输出的程序。着色器也是一种非常独立的程序,因为它们之间不能相互通信;它们之间唯一的沟通只有通过输入和输出。
GLSL
着色器是使用一种叫GLSL的类C语言写成的。GLSL是为图形计算量身定制的,它包含一些针对向量和矩阵操作的有用特性。 着色器的开头总是要声明版本,接着是输入和输出变量、uniform和main函数。每个着色器的入口点都是main函数,在这个函数中我们处理所有的输入变量,并将结果输出到输出变量中。 一个典型的着色器结构如下
|
|
当我们特别谈论到顶点着色器的时候,每个输入变量也叫顶点属性(Vertex Attribute),我们能声明的顶点属性是有上限的,它一般由硬件来决定。OpenGL确保至少有16个包含4分量的顶点属性可用
数据类型
基础数据类型有:int、float、double、uint、bool
另外还有向量
向量可以有一些有趣的重组操作
|
|
输入输出
GLSL定义了in和out关键字专门来实现这个目的。每个着色器使用这两个关键字设定输入和输出,只要一个输出变量与下一个着色器阶段的输入匹配,它就会传递下去。在顶点着色器和片段着色器有一些不同。
顶点着色器的输入特殊在,它从顶点数据中直接接收输入。为了定义顶点数据该如何管理,我们使用location这一元数据指定输入变量,这样我们才可以在CPU上配置顶点属性。例如layout (location = 0),除此之外也可以这样设置
|
|
这样设置的情况下,glsl就不要写location了
|
|
在片段着色器中,需要一个vec4颜色的输出变量,因为他的目的就是生成最终的颜色。如果你在片段着色器没有定义输出颜色,OpenGL会把你的物体渲染为黑色(或白色)。所以,如果我们打算从一个着色器向另一个着色器发送数据,我们必须在发送方着色器中声明一个输出,在接收方着色器中声明一个类似的输入。当类型和名字都一样的时候,OpenGL就会把两个变量链接到一起,它们之间就能发送数据了(这是在链接程序对象时完成的),下面是一个例子,vertexColor变量就是从顶点着色器跑到片段着色器的
|
|
Uniform
uniform是全局的(Global)。全局意味着uniform变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。第二,无论你把uniform值设置成什么,uniform会一直保存它们的数据,直到它们被重置或更新。
|
|
如果你声明了一个uniform却在GLSL代码中没用过,编译器会静默移除这个变量,导致最后编译出的版本中并不会包含它,这可能导致几个非常麻烦的错误,记住这点!
这个uniform现在还是空的;我们还没有给它添加任何数据,所以下面我们就做这件事。我们首先需要找到着色器中uniform属性的索引/位置值。当我们得到uniform的索引/位置值后,我们就可以更新它的值了。调用glUseProgram来设置uniform的值
|
|
最后提一点,如果在VBO中不止位置属性,还有颜色属性如下图
可以这样设置
|
|
把原本的代码写在这里记录一下
|
|
下来给出一个封装的shader类
|
|
|
|
纹理
首先需要给每个顶点一个纹理坐标
|
|
纹理坐标的范围通常是从(0, 0)到(1, 1),当我们设置成别的区域时,OpenGL通过参数调整有不同的表达方式
可以对每个坐标轴的行为进行单独控制
|
|
纹理过滤
首先介绍一个概念:纹理像素,打开一张图片的每个像素就是纹理像素。OpenGL以一个顶点设置的纹理坐标数据去查找纹理图像上的像素,然后进行采样提取纹理像素的颜色。当我需要渲染到一片很大的光栅时,每个像素点插值出来的纹理坐标没法直接对应纹理图片的一个像素(即纹理像素),比如纹理坐标为(0.1,0.1)时,如果原纹理图片是10x10的,这就表示第一行第一列的那个纹理像素的颜色作为该像素点的颜色(当然真实情况是0+0.5才是一个纹理像素的中心,这里只是便于理解),但是如果纹理坐标插值出来是(0.11,0.11)那就没法直接对应了,就需要特殊处理了。 OpenGL也有对于纹理过滤(Texture Filtering)的选项。纹理过滤有很多个选项,但是现在我们只讨论最重要的两种:GL_NEAREST和GL_LINEAR。
- GL_NEAREST(也叫邻近过滤,Nearest Neighbor Filtering)是OpenGL默认的纹理过滤方式,即选择离纹理坐标最接近的纹理像素的颜色

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

Mipmap 多级渐远纹理
有些物体会很远,但其纹理会拥有与近处物体同样高的分辨率。比如一个物体光栅化后只占了2x2的像素,但他的纹理图片有10x10,它需要对一个跨过纹理很大部分的片段只拾取一个纹理颜色。在小物体上这会产生不真实的感觉,更不用说对它们使用高分辨率纹理浪费内存的问题了。
OpenGL使用一种叫做多级渐远纹理(Mipmap)的概念来解决这个问题,它简单来说就是一系列的纹理图像,后一个纹理图像是前一个的二分之一。多级渐远纹理背后的理念很简单:距观察者的距离超过一定的阈值,OpenGL会使用不同的多级渐远纹理,即最适合物体的距离的那个。由于距离远,解析度不高也不会被用户注意到。同时,多级渐远纹理另一加分之处是它的性能非常好。让我们看一下多级渐远纹理是什么样子的
离摄像机距离较远的物体采样时,在小纹理上采样效果更好。对Mipmap的使用也有多种过滤方式
最后一种参数额外介绍一下GL_LINEAR_MIPMAP_LINEAR(三线性过滤),在最接近的两个mipmap上进行线性插值,最后混合结果,计算量时最大的
|
|
第一行是对渲染物体小于纹理图片时,使用Mipmap,第二个是当渲染物体大于纹理图片时的设置
实际使用方式
stb_image.h是Sean Barrett的一个非常流行的单头文件图像加载库,下载源代码后使用方式如下
|
|
首先加载一个纹理图片。这个函数首先接受一个图像文件的位置作为输入。接下来它需要三个int作为它的第二、第三和第四个参数,stb_image.h将会用图像的宽度、高度和颜色通道的个数填充这三个变量。我们之后生成纹理的时候会用到的图像的宽度和高度的。
|
|
对纹理的管理与之前那些Object类似,也得创建id
|
|
紧接着也需要进行绑定,这样之后对纹理的设置都是设置绑定的纹理
|
|
下来就是把载入的图片生成纹理了,上边的绑定就是为了下边设置时不需要再考虑是给谁设置了
|
|
下来设置一下纹理的环绕和过滤方式
|
|
这样就设置好纹理了,当然顶点数据还要额外存储纹理坐标,不然每个像素去纹理图哪个位置找还不知道,在VBO中存储顶点位置、颜色、纹理坐标,同时需要告诉VAO
|
|
告诉VAO,纹理数据是如何存储的,即每个顶点数据站8个字节,纹理数据的偏移为6
|
|
同时需要在顶点着色器中接收参数,接收了每个顶点的纹理坐标,并输出到片段着色器中
|
|
片段着色器接收插值后的纹理坐标和纹理图片(uniform ) texture是本身就有的函数,根据输入的纹理图片和纹理坐标,根据之前对纹理图片的设置进行采样
|
|
现在只剩下在调用glDrawElements之前绑定纹理了,它会自动把纹理赋值给片段着色器的采样器
|
|
如果你的纹理代码不能正常工作或者显示是全黑,请继续阅读,并一直跟进我们的代码到最后的例子,它是应该能够工作的。在一些驱动中,必须要对每个采样器uniform都附加上纹理单元才可以,这个会在下面介绍。
纹理单元
我们通过uniform设置的纹理,但是没有在代码中给他赋值呀。使用glUniform1i,我们可以给纹理采样器分配一个位置值,这样的话我们能够在一个片段着色器中设置多个纹理。一个纹理的默认纹理单元是0,它是默认的激活纹理单元,所以教程前面部分我们没有分配一个位置值。纹理单元的主要目的是让我们在着色器中可以使用多于一个的纹理。通过把纹理单元赋值给采样器,我们可以一次绑定多个纹理,只要我们首先激活对应的纹理单元。
|
|
激活纹理单元之后,绑定的纹理会绑定到激活的纹理单元。OpenGL至少保证有16个纹理单元供你使用,也就是说你可以激活从GL_TEXTURE0到GL_TEXTRUE15。
就这块东西可以理解为一个像素点的颜色不一定只来自于一张纹理图,比如普通贴图、法线贴图、高光贴图共同配合才能完成一个很好的效果,具体例子可以看我之前的tinyrenderer相关博客的例子。https://blog.csdn.net/lzh804121985/article/details/146939272?spm=1001.2014.3001.5502(直接拉到最后看各种图片)
当设置两个不同的纹理图时,要修改片段着色器的内容,这里就是混合两张纹理图的内容
|
|
因为这里定义的其实是两个采样器,而不是两个纹理,我觉得修改名字比较直观
|
|
最后就需要指定每个sample对应的是哪个纹理单元了
|
|
完整代码如下
|
|
用Clion的一定要注意,要修改debug文件夹下的着色器代码才能生效。。。
最终图片

坐标系统
GLM是OpenGL Mathematics的缩写,它是一个只有头文件的库,也就是说我们只需包含对应的头文件就行了,不用链接和编译。GLM可以在它们的网站上下载。把头文件的根目录复制到你的includes文件夹,然后你就可以使用这个库了。
OpenGL希望在每次顶点着色器运行后,我们可见的所有顶点都为标准化设备坐标(Normalized Device Coordinate, NDC)。也就是说,每个顶点的x,y,z坐标都应该在-1.0到1.0之间,超出这个坐标范围的顶点都将不可见。也就是说OpenGL顶点着色器执行完后,会对不在NDC范围内的点进行剔除。
games101学过很多了,MVP+透视除法+视口变换,让一个三维物体转为二维坐标
局部空间(Local Space,或者称为物体空间(Object Space))
世界空间(World Space)
观察空间(View Space,或者称为视觉空间(Eye Space))
裁剪空间(Clip Space)
屏幕空间(Screen Space)
对于局部空间、世界空间、观察空间这里就不做解释了
裁剪空间
在一个顶点着色器运行的最后,OpenGL期望所有的坐标都能落在一个特定的范围内,且任何在这个范围之外的点都应该被裁剪掉(Clipped)。被裁剪掉的坐标就会被忽略,所以剩下的坐标就将变为屏幕上可见的片段。这也就是裁剪空间(Clip Space)名字的由来。
如果只是图元(Primitive),例如三角形,的一部分超出了裁剪体积(Clipping Volume),则OpenGL会重新构建这个三角形为一个或多个三角形让其能够适合这个裁剪范围。
例如下图
一旦所有顶点被变换到裁剪空间,最终的操作——透视除法(Perspective Division)将会执行,在这个过程中我们将位置向量的x,y,z分量分别除以向量的齐次w分量。透视除法是将4D裁剪空间坐标变换为3D标准化设备坐标的过程。这一步会在每一个顶点着色器运行的最后被自动执行。
在OpenGL中只需要处理MVP矩阵,裁剪、透视除法和视口变换会自动处理。
来直接开干把!
- 模型变换矩阵,通过将顶点坐标乘以这个模型矩阵,我们将该顶点坐标变换到世界坐标。我们的平面看起来就是在地板上,代表全局世界里的平面。
|
|
- 视图变换矩阵
|
|
- 透视投影矩阵
|
|
把这些矩阵传入顶点着色器
|
|
整体如下
|
|
setMat4是新添加的方法
|
|
现在图片躺下了
后边这个立方体就不做了,就是顶点都写进去,然后设置好VAO,之后在循环中修改MVP矩阵的M矩阵,他就转起来了,但目前还没有考虑Z-buffer。**GLFW会自动为你生成这样一个缓冲)**我们想要确定OpenGL真的执行了深度测试,首先我们要告诉OpenGL我们想要启用深度测试;它默认是关闭的。我们可以通过glEnable函数来开启深度测试。glEnable和glDisable函数允许我们启用或禁用某个OpenGL功能
|
|
摄像机
OpenGL本身没有摄像机(Camera)的概念,但我们可以通过把场景中的所有物体往相反方向移动的方式来模拟出摄像机,产生一种我们在移动的感觉,而不是场景在移动。在上一节中我们通过把所有物体向后平移3格,来模拟摄像机在(0,0,3)的位置上。
要定义一个摄像机,我们需要它在世界空间中的位置、观察的方向、一个指向它右侧的向量以及一个指向它上方的向量。实际上创建了一个三个单位轴相互垂直的、以摄像机的位置为原点的坐标系。 首先来指定一个摄像机位置
|
|
摄像机的方向就指向场景的原点即可,相减就得到了摄像机的指向方向,但是取反转方向,也就是是指向+Z的,与摄像机的朝向相反
|
|
之后还需要一个右向量作为x方向,可以通过上向量与指向方向叉乘得到
|
|
最后y方向就可以通过上和右的叉乘得到
|
|
使用上面得到的x\y\z向量可以构建lookAt矩阵,可以用这个矩阵乘以任何向量来将其变换到摄像机坐标空间

其中R是右向量,U是上向量,D是方向向量,P是摄像机位置向量,可以直接通过GLM生成这个矩阵
|
|
glm::LookAt函数需要一个位置、目标和上向量 如果我们在View矩阵构建时使用时间,就会让整个场景开始旋转
|
|
自由移动
|
|
通过上边参数控制,就用这些vec3来控制lookAt矩阵的生成了,下来就用监听来绑定按键修改参数,就是改变Pos来实现的
|
|
这里设置一个固定的移动速度是有bug的,因为每一帧处理一次移动,如果硬件比较好的条件下,帧数高,速度就会快一点。图形程序和游戏通常会跟踪一个时间差(Deltatime)变量,它储存了渲染上一帧所用的时间。我们把所有速度都去乘以deltaTime值
|
|
视角移动
为了能够改变视角,我们需要根据鼠标的输入改变cameraFront向量。这里需要一点三角学的知识
欧拉角:俯仰角(Pitch)、偏航角(Yaw)和滚转角(Roll),每个欧拉角都有一个值来表示,把三个角结合起来我们就能够计算3D空间中任何的旋转向量了。
假设我们现在在XZ屏幕,往Y偏移就是俯仰角
假设俯仰角为pitch
|
|
对偏航角的处理一个道理
通过俯仰角的计算已经知道了斜边长是cos(pitch),一结合,就得如果已知俯仰角和偏航角,就可以知道(x,y,z)坐标了
|
|
偏航角和俯仰角是通过鼠标(或手柄)移动获得的,水平的移动影响偏航角,竖直的移动影响俯仰角。它的原理就是,储存上一帧鼠标的位置,在当前帧中我们当前计算鼠标位置与上一帧的位置相差多少。如果水平/竖直差别越大那么俯仰角或偏航角就改变越大,也就是摄像机需要移动更多的距离。 首先让光标消失
|
|
之后注册一个鼠标移动监听的回调
|
|
我们必须在程序存储上一帧鼠标的位置,再看这一帧的变化,计算出角度进而计算出摄像机位置,修改LookAt矩阵
|
|
另外场景的放大缩小可以通过控制透视矩阵的fov来控制 最后提供一个封装好的摄像机类
|
|
最终能运行的整体代码
|
|