题记:关于OpenGL的学习还是需要从基础学起,可以参考LearnOpenGL CN给出了很多例子。本章会对OpenGL做整体导引,结合自己在学习中有疑问地方加以说明,尽量让每一位读者有所收获。本人音视频学习Demo也有OpenGL在相机、视频方面的应用,文章或代码若有错误,也希望大佬不吝赐教。
一、OpenGL概念
OpenGL(Open Graphics Library)是由Khronos组织制定并维护的规范,对于开发者而言可以理解为是一个跨平台、跨语言的图形渲染API。
OpenGL ES(OpenGL for Embedded Systems)是OpenGL的简化版本,专为移动设备、嵌入式系统而设计。
1.1 状态机
在学习OpenGL时需要首先需要理解的是状态机(初学时很容易迷惑的点),通过一系列内部状态来控制渲染流程。状态机本质:
- 全局状态存储:状态(如顶点数据、着色器、纹理)存储在GPU的全局内存中
- 状态驱动:绘制操作都依赖当前状态,"
设置-使用
"模式 - 隐式切换:某些状态会在函数调用时自动修改(如
glUseProgram
切换着色器)
状态机示例
-
顶点输入,如VAO、VBO、EBO、顶点属性指针
/// VAO 绑定示例 GLuint vao; glGenVertexArrays(1, &vao); // 生成vao对象 glBindVertexArray(vao); // 进入VAO状态上下文 /// 在VAO内部配置VBO GLuint vbo; glGenBuffers(1, &vbo); // 生成vbo对象 glBindBuffer(GL_ARRAY_BUFFER, vbo); // 进入VBO状态上下文 // 将attrBuffer绑定到GL_ARRAY_BUFFER标识符上 glBindBuffer(GL_ARRAY_BUFFER, vbo); // 把顶点数据从CPU内存复制到GPU上 glBufferData(GL_ARRAY_BUFFER, sizeof(arr), arr, GL_STREAM_DRAW); glBindVertexArray(0); // 退出VAO上下文
这里注意
glBufferData
作用把顶点数据从CPU内存复制到GPU上,arr
是CPU数组,但并没有GPU相关的参数,就是因为是在VBO的上下文,作用在vbo
上。 -
着色器 激活当前的顶点/片段着色器,其后的参数赋值便是在作用在这个program
/// 使用当前着色器程序 /// loadShader(vert, frag); /// glLinkProgram(shaderProgram); glUseProgram(shaderProgram)
-
纹理绑定
glGenTextures(1, &texture); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, texture); /// 参数texture设置 glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, textureOptions.minFilter); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, textureOptions.magFilter); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, textureOptions.wrapS); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, textureOptions.wrapT);
glTexParameterf
用于设置纹理渲染属性,这里并没有指定哪个纹理的参数。而是通过前面的glBindTexture
绑定了texture
上,从而作用在texture
上。 -
功能启动/关闭及相关函数 如混合、深度测试、模板
// 启用颜色混合 glEnable(GL_BLEND) // 关闭颜色混合 glDisable(GL_BLEND) // 设置颜色混合方式 glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
最初学习OpenGL时,理解的状态机也就是glEnable
和glDisable
,经常因为函数没有包含以为必须得参数而迷惑,所以这里会放在最前面,对其中涉及到的内容下文会一一讲解。
1.2 上下文
OpenGL上下文是OpenGL渲染状态和资源的容器,所有OpenGL操作(如绘制、纹理绑定、着色器编译等)都必须在当前上下文中执行。不同平台通过不同的底层API创建和管理OpenGL上下文。
-
LearnOpenGL CN创建窗口和上下文
glfwInit(); glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); glfwMakeContextCurrent(window); // 激活上下文
-
iOS上创建上下文
- (void)setupGL { // 创建上下文 _context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES3]; [EAGLContext setCurrentContext:_context]; }
需要注意的是:上下文与线程绑定,同一线程同一时间只能有一个激活的上下文。此外,可以通过共享上下文实现资源(如纹理、缓冲区对象等)的复用。
1.3 渲染流程
OpenGL渲染流程将原始几何数据转化为屏幕像素的步骤,流程化的步骤就是渲染管线,渲染管线又可划分为固定管线
和可编程管线
,本系列主要讨论可编程管线
(OpenGL ES 2.x以后就只支持可编程管线了)。
类比作画过程,顶点变换就是确定3D画作的点位,细分和几何着色器是可选的,并且OpenGL ES支持有限,暂时不考虑。图元转配就像画轮廓线,根据指定点、线、面画出图像边框。图形映射到屏幕上变成像素块显示,这一步就是光栅化。再将像素块一个个填充颜色,就是逐片段操作,最后写入画板(帧缓冲区)。效果就是资料上常看到的:
着色器程序和输入是用户可操作的,其他由OpenGL完成,具体来看下:
1.3.1 顶点数据输入(Vertex Data Input)
-
作用:向OpenGL提供顶点属性(位置、颜色、纹理坐标等)。
-
实现方式:
- 将顶点数据存储在顶点缓冲区对象(VBO)。LearnOpenGL中有VAO和VEO和使用。
//前3个是顶点坐标,后2个是纹理坐标 GLfloat attrArr[] = { 1.0f, -1.0f, 0.0f, 1.0f, 0.0f, // 右下 -1.0f, 1.0f, 0.0f, 0.0f, 1.0f, // 左上 -1.0f, -1.0f, 0.0f, 0.0f, 0.0f, // 左下 1.0f, 1.0f, 0.0f, 1.0f, 1.0f, // 右上 -1.0f, 1.0f, 0.0f, 0.0f, 1.0f, // 左上 1.0f, -1.0f, 0.0f, 1.0f, 0.0f, // 右下 }; GLuint attrBuffer; //(2)申请一个缓存区标识符 glGenBuffers(1, &attrBuffer); //(3)将attrBuffer绑定到GL_ARRAY_BUFFER标识符上 glBindBuffer(GL_ARRAY_BUFFER, attrBuffer); //(4)把顶点数据从CPU内存复制到GPU上 glBufferData(GL_ARRAY_BUFFER, sizeof(attrArr), attrArr, GL_STREAM_DRAW);
-
说明:
上述代码中给出了一个基础屏幕贴图的坐标数组(定义了6个点,每个点有5个浮点数组成,这些意义需要再后面的指定才有效,现在就是一个数组)。关于坐标变换,后续展开,这里简单说一下,先不考虑z轴和坐标变换,那么将一个纹理(图片)画到屏幕上会有如下对应。
- 顶点坐标原位在屏幕中心
- 纹理坐标原点在左下角(图片文件通常从左上为原点)
此外,OpenGL ES只支持点、线、三角形,而矩形需要2次绘制,所以定义了定义了6个点位(有重复),当然如果使用了索引绘图,也可以定义4个点位,然后索引指定6个点。
1.3.2 顶点着色器(Vertex Shader)
着色器程序是运行的GPU上的程序,这里先不用完全懂,只需要理解流程就可以。下一篇会专门讲解。
-
作用:
- 处理顶点的坐标变换(模型视图投影矩阵)。
- 传递顶点属性(颜色、纹理坐标)到后续阶段。
-
示例:
顶点着色器代码如下,
vec4
为4维度向量,gl_Position
是OpenGL内置变量,就是要绘制的点坐标,textureCoordinate
透传到片段着色器这里不用:attribute vec4 position; attribute vec4 inputTextureCoordinate; varying vec2 textureCoordinate; void main() { gl_Position = position; textureCoordinate = inputTextureCoordinate.xy; }
再看一下,如何指定这些值
// 获取着色器中对应的位置 id GLuint position = program->attributeIndex("position"); // 启用对应的顶点属性 glEnableVertexAttribArray(position); // 指定数据段,从0开始的3个GL_FLOAT类型,每次偏移sizeof(GLfloat) * 5, NULL) glVertexAttribPointer(position, 3, GL_FLOAT, GL_FALSE, sizeof(GLfloat) * 5, NULL); // 获取着色器中对应的纹理 id GLuint textCoor = program->attributeIndex("inputTextureCoordinate"); glEnableVertexAttribArray(textCoor); // 指定数据段,从(float *)NULL + 3开始的2个GL_FLOAT类型 glVertexAttribPointer(textCoor, 2, GL_FLOAT, GL_FALSE, sizeof(GLfloat) * 5, (float *)NULL + 3);
-
说明:
-
attributeIndex
:这里是通过glBindAttribLocation
绑定好的,也可以通过glGetAttribLocation
获取,这里就可以理解为找到属性id -
glEnableVertexAttribArray
:启用属性,默认情况下,出于性能考虑,所有顶点着色器的属性(Attribute)变量都是关闭的,意味着数据在着色器端是不可见的。 -
glVertexAttribPointer
原型:void glVertexAttribPointer( GLuint index, // 顶点属性位置(对应着色器中 layout(location=N)) GLint size, // 每个顶点的分量数量(如位置是3分量:x,y,z) GLenum type, // 数据类型(如 GL\_FLOAT、GL\_INT) GLboolean normalized, // 是否归一化整型数据到 \[0,1] 或 \[-1,1] GLsizei stride, // 顶点间的步长(字节数),0表示紧密排列 const void\* pointer // 数据在缓冲区的起始偏移量(字节) )
注意这里没有数据源,是因为前文说的状态机,这里的操作的数据就是前面的vbo,也就是前文
attrArr
在显存里的Copy体。这时候attrArr
才从一堆数组变成了点位。代码接上文。- 顶点着色器程序会处理6次(偏移为
sizeof(GLfloat) * 5
) - 每组的前3个给
position
,position
实际是vec4
,这里是对前3项赋值,第4项默认为1.0
,如果是正交投影,写成vec4(position.xyz, 1.0)
- 后2个给
inputTextureCoordinate
,inputTextureCoordinate
实际是vec4
,这里是对前2项赋值,后两项不用。
-
1.3.2 图元装配(Primitive Assembly)
- 作用:将顶点按指定图元类型组装成几何形状。
在
glDrawArrays
指定的类型,点、线、三角形,下文就是渲染2个三角形(6个顶点)。 //12.绘图 glDrawArrays(GL_TRIANGLES, 0, 6); - 可选阶段:
- 细分着色器(Tessellation Shader) :动态增加几何细节。
- 几何着色器(Geometry Shader) :修改或生成新图元(如细分三角形)。
1.3.4 光栅化(Rasterization)
-
作用:将几何图元转换为 片段(Fragment) (像素化)。 图片来源(www.scratchapixel.com/lessons/3d-…
-
关键过程:
- 裁剪(Clipping):丢弃视口外的部分。
坐标系详解
- 透视除法:将顶点坐标从裁剪空间转换为标准化设备坐标(NDC)。
坐标系详解
- 视口变换:将NDC映射到屏幕实际像素坐标。
坐标系详解
- 插值计算:对顶点属性(颜色、纹理坐标)在片段间进行插值。
- 顶点着色器只处理顶点(6个)
- 光栅化后要处理渲染区域的所有像素点
- 裁剪(Clipping):丢弃视口外的部分。
1.3.5 片段着色器(Fragment Shader)
- 作用:计算每个片段的最终颜色(含光照、纹理采样等)。
-
代码示例
片段着色器代码定义如下,
textureCoordinate
和顶点着色器一致,是数据顶点着色器传过来的,整体上就是一个纹理采样。varying highp vec2 textureCoordinate; uniform sampler2D inputImageTexture; void main() { gl_FragColor = texture2D(inputImageTexture, textureCoordinate); }
gl_FragColor
是内置变量,片段的颜色,如果是纯色就可以定义为gl_FragColor = vec4(1.0, 0, 0, 1.0)
。inputImageTexture
是处理的纹理单元,先理解为纹理(图片)。texture2D
就是对纹理进行采样,这里注意输入时只传了顶点的值,而片段的值就是通过插值得到的。
1.3.6 测试与混合(Per-Fragment Operations)
- 作用:判断是否写入帧缓冲区
- 关键测试:
-
深度测试(Depth Test) :丢弃被遮挡的片段
glEnable(GL_DEPTH_TEST); glDepthFunc(GL_LESS);
-
模板测试(Stencil Test) :按模板缓冲区规则过滤片段。
glEnable(GL_STENCIL_TEST); glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);
-
混合(Blending) :透明物体的颜色混合
glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
-
1.3.7 写入FrameBuffer
处理完上述操作,会被接入一个帧缓冲区,此时可以用于展示到屏幕、可以作为下一个滤镜输入、也可以通过read操作读成图片存储,在1.5节详述。
1.4 坐标空间
一般来说,资料上都会介绍如下空间系统,但实际上顶点着色器gl_Position
输出的就是裁剪空间的坐标。
而上述的变换最早来自固定管线的设计,可编程管线实际没有限制(如上述例子中直接给(1.0f, -1.0f, 0.0f)
这样的值),但一般会沿用这样的设计思路
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vertexPosition;
1.4.1 坐标系统
-
模型空间(Model Space / Local Space)
- 定义:模型的局部坐标系
- 变换矩阵:模型矩阵(modelMatrix) ,通过平移、旋转、缩放将模型从局部坐标转换到世界坐标。
-
世界空间(World Space)
- 定义:全局坐标系,原点为场景的原点。
- 作用:统一不同模型的坐标。
-
观察空间(View Space / Camera Space)
- 定义:以相机为原点的坐标系。
- 变换矩阵:视图矩阵(View Matrix) ,将世界坐标转换为相机视角下的坐标。
-
裁剪空间(Clip Space)
- 定义:顶点经过投影后的坐标空间
- 变换矩阵:
- 正交投影(Orthographic) :无透视效果
- 透视投影(Perspective) :模拟近大远小
- 坐标范围:顶点坐标被映射到裁剪立方体(范围
[-w, w]
),超出部分被裁剪。
-
标准化设备坐标(NDC, Normalized Device Coordinates)
- 转换过程:通过透视除法(即
(x/w, y/w, z/w)
)将裁剪坐标转换为NDC。 - 范围:每个坐标轴范围
[-1, 1]
,超出范围的顶点被丢弃或裁剪。
- 转换过程:通过透视除法(即
-
屏幕空间(Screen Space / Window Space)
- 视口变换:将 NDC 映射到窗口的像素坐标。
- 操作:通过
glViewport(x, y, width, height)
设置视口范围和深度(通常。
这种 ”模型空间->世界空间->观察空间->裁剪空间“的设计给开发带来了很大便利,尤其复杂3D场景(如苹果在桌子上,桌子在房屋内,房屋在村子里),模型矩阵也可以是多个,通过push、pop矩阵变换场景。“NDC->屏幕空间”则是OpenGL里的固定流程,开发者也不需要管理。
1.4.2 坐标变换公式
-
基本变换(为了用矩阵实现平移变换,加了一个维度)
-
平移,移动(tx,ty,tz)
-
旋转,z轴旋转m
-
缩放,n倍缩放
-
-
模型矩阵
变换按照 缩放->旋转->平移的顺序(平移在最左边,缩放最右)。同时矩阵相乘满足结合律,所以模型矩阵可以嵌套多个,一些复杂的变化会用
stack
管理模型变换,进入场景push model,出场景pop。 -
视图矩阵
视图变换本质上也是平移和旋转,以眼睛(或摄像机)为原点,以世界坐标的某一点(一般选原点,不是原点也可以通过平移)为观察点,就是一个平移关系,再指定一个向上方向,就是一个旋转关系。
F=normalize(target-eye)
F是摄像机正向方向,-F对应Z轴方向的单位向量R=normalize(F×up)
up是参考上方向,R是X轴的单位向量U=R×F
实际就是up在垂直方向的投影,对应Y轴单位向量(up不一定垂直F)T
代表平移矩阵,(Ex,Ey,Ez)
是摄像机位置,新坐标为原点R
为旋转矩阵,更简单的理解就是求点P
在(R,U,-F)坐标系统的投影,就是新坐标系统的值。例如P在R方向的投影P*R = Px*Rx+Py*Rx+Pz*Rz
就是新坐标的x值。R是单位向量。
-
投影矩阵
投影变换就是划定视野范围,在视野范围外面的就裁剪掉。视野范围称为视景体,视景体以外的色块会被裁掉。由于标准化设备坐标(NDC) 的范围在[-1, 1],本质上就是把视景体变成边长为2的立方体,正交变换只需要缩放,投影变换需要改变切面大小。
-
正交投影 正交投影只需要把中心点移动到原点,在按比例缩放
2/原边长
,因此有下面计算。 -
透视投影 透视投影有远小近大的效果,推算过程也相对复杂一些。考虑最普遍的情况,视景体对称情况。对任意一点
P
做截面,如图。图中的虚线应该相交于原点(Camera位置),可知- P所在截面与近平面和远平面都是相似的,比例与Z坐标有关,
近平面宽度/P平面宽度 = near/(-z),z是负值
。 - 对于P平面的x,如果要变换到[-1, 1],按照正交类推,变换应该是
2/(right-(-right)) * near/(-z)
即near/(right * (-z))
,这里可得到与z相关,也就是近大远小的关键。 - 矩阵不能包含未知量z,所以这里会扩大(-z)倍,就是就是透视除法的来源。即范围扩大为
[z,-z]
, 矩阵变换后最后的一个分量为-z。[,,,1] => [,,,-z]
.
- 于是矩阵做如下调整,最后P的最后一个分量变换后相当于参数(原来是1并没有用),透视除法也就是其他分量除以该值就得到[-1,1]范围:
-
矩阵中最后两个值,可以推导出来,当z=-near,结果为z(透视除法后为-1),当z=-far时,结果为-z(透视除法1):
-
投影矩阵为:
- P所在截面与近平面和远平面都是相似的,比例与Z坐标有关,
-