音视频学习笔记十一——渲染与滤镜之OpenGL基础一

236 阅读14分钟

题记:关于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时,理解的状态机也就是glEnableglDisable,经常因为函数没有包含以为必须得参数而迷惑,所以这里会放在最前面,对其中涉及到的内容下文会一一讲解。

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以后就只支持可编程管线了)。

渲染流程.jpg

类比作画过程,顶点变换就是确定3D画作的点位,细分和几何着色器是可选的,并且OpenGL ES支持有限,暂时不考虑。图元转配就像画轮廓线,根据指定点、线、面画出图像边框。图形映射到屏幕上变成像素块显示,这一步就是光栅化。再将像素块一个个填充颜色,就是逐片段操作,最后写入画板(帧缓冲区)。效果就是资料上常看到的:

渲染流程效果图.jpg

着色器程序和输入是用户可操作的,其他由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轴和坐标变换,那么将一个纹理(图片)画到屏幕上会有如下对应。

    • 顶点坐标原位在屏幕中心
    • 纹理坐标原点在左下角(图片文件通常从左上为原点)
    坐标输入.jpg

    此外,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个给positionposition实际是vec4,这里是对前3项赋值,第4项默认为1.0,如果是正交投影,写成vec4(position.xyz, 1.0)
    • 后2个给inputTextureCoordinateinputTextureCoordinate实际是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-… 光栅化.jpg

  • 关键过程

    • 裁剪(Clipping):丢弃视口外的部分。坐标系详解
    • 透视除法:将顶点坐标从裁剪空间转换为标准化设备坐标(NDC)。坐标系详解
    • 视口变换:将NDC映射到屏幕实际像素坐标。坐标系详解
    • 插值计算:对顶点属性(颜色、纹理坐标)在片段间进行插值。
      • 顶点着色器只处理顶点(6个)
      • 光栅化后要处理渲染区域的所有像素点

1.3.5 片段着色器(Fragment Shader)

  • 作用:计算每个片段的最终颜色(含光照、纹理采样等)。
WeChat97e71c70a919822e59b929950081abb8.jpg
  • 代码示例

    片段着色器代码定义如下,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输出的就是裁剪空间的坐标。 image.png

而上述的变换最早来自固定管线的设计,可编程管线实际没有限制(如上述例子中直接给(1.0f, -1.0f, 0.0f)这样的值),但一般会沿用这样的设计思路

gl_Position = projectionMatrix * viewMatrix * modelMatrix * vertexPosition;

1.4.1 坐标系统

投影变换.png
  • 模型空间(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)

      image.png
    • 旋转,z轴旋转m image.png

    • 缩放,n倍缩放

      image.png
  • 模型矩阵

    变换按照 缩放->旋转->平移的顺序(平移在最左边,缩放最右)。同时矩阵相乘满足结合律,所以模型矩阵可以嵌套多个,一些复杂的变化会用stack管理模型变换,进入场景push model,出场景pop。

  • 视图矩阵

    视图变换本质上也是平移和旋转,以眼睛(或摄像机)为原点,以世界坐标的某一点(一般选原点,不是原点也可以通过平移)为观察点,就是一个平移关系,再指定一个向上方向,就是一个旋转关系。

    投影坐标系.jpg
    • 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是单位向量。
    image.png
  • 投影矩阵

    投影变换就是划定视野范围,在视野范围外面的就裁剪掉。视野范围称为视景体,视景体以外的色块会被裁掉。由于标准化设备坐标(NDC) 的范围在[-1, 1],本质上就是把视景体变成边长为2的立方体,正交变换只需要缩放,投影变换需要改变切面大小。

    投影变换.jpg

    • 正交投影 正交投影只需要把中心点移动到原点,在按比例缩放2/原边长,因此有下面计算。

      正交投影.jpg
    • 透视投影 透视投影有远小近大的效果,推算过程也相对复杂一些。考虑最普遍的情况,视景体对称情况。对任意一点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].
      投影变换点位.jpg
      • 于是矩阵做如下调整,最后P的最后一个分量变换后相当于参数(原来是1并没有用),透视除法也就是其他分量除以该值就得到[-1,1]范围:
      透视投影.jpg
      • 矩阵中最后两个值,可以推导出来,当z=-near,结果为z(透视除法后为-1),当z=-far时,结果为-z(透视除法1):

        投影推理.jpg
      • 投影矩阵为:

      透视方程.jpg