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

366 阅读9分钟

题记:本章会继续介绍OpenGL基础,参考LearnOpenGL CN能学到很多。本章会介绍OpenGL绘图部分,同样结合自己在学习中有疑问地方加以说明。本人音视频学习Demo也有OpenGL在相机、视频方面的应用,文章或代码若有错误,也希望大佬不吝赐教。

一、OpenGL渲染基础

1.1 FBO

关于Frame Buffer Object,在iOS端图像渲染有所介绍。在LearnOpenGL CN中FBO属于高级内容,但其实在iOS中使用CAEAGLLayer就会不可避免用到,同时它也是理解滤镜(美颜等)的基础。

1.1.1 画框

FBO可以理解为OpenGL的画框,或者说可以自定义的画布框架。之所以这么说是因为FBO本身不存储数据,而是管理附件,附件一般用于存储颜色和深度模版等数据。

FBO.png

图中左侧可以类比理解为画布,右侧可以类比为卡槽。画布有两种“材质”,RBO和纹理,卡槽又分为颜色、深度、模板。可以参考前面

1.1.1.1 RBO VS 纹理

  • RBO

    渲染缓冲对象(Render Buffer Object, RBO) 是一种用于临时存储渲染数据的缓冲对象。它专门设计用于高效存储 颜色、深度或模板数据,但不支持直接通过着色器采样。

    //1.定义一个缓存区
    GLuint buffer;
    //2.申请一个缓存区标志
    glGenRenderbuffers(1, &buffer);
    //3.
    self.colorRenderBuffer = buffer;
    //4.将标识符绑定到GL_RENDERBUFFER
    glBindRenderbuffer(GL_RENDERBUFFER, self.colorRenderBuffer);
    // 分配深度和模板联合存储(24位深度 + 8位模板)
    glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, width, height);
    // 绑定 FBO
    glBindFramebuffer(GL_FRAMEBUFFER, fbo);  
    // 附加为深度和模板附件
    glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);
    

    iOS中用于渲染CAEAGLLayer时仅需要替换存储语句。

    [self.context renderbufferStorage:GL_RENDERBUFFER fromDrawable:self.gpuLayer];
    
  • 纹理

    纹理下文中会有更加详细说明,这里介绍的是绑定到FBO。

      // 创建纹理
      GLuint texture;
      glGenTextures(1, &texture);
      glBindTexture(GL_TEXTURE_2D, texture);
      glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);
    
      // 创建 FBO 并附加纹理
      GLuint fbo;
      glGenFramebuffers(1, &fbo);
      glBindFramebuffer(GL_FRAMEBUFFER, fbo);
      glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
    
  • 对比

    特性渲染缓冲对象(RBO)纹理(Texture)
    设计目的临时存储渲染数据(颜色、深度、模板)存储可复用的图像数据,支持采样和着色器访问
    数据访问只能通过 glReadPixels 读取像素数据支持通过纹理采样器(sampler2D)在着色器中直接访问
    性能优化适合一次性操作(如深度测试)适合需要后续复用数据的场景(如后处理)
    内存管理数据存储由 OpenGL 驱动管理,不可直接操作可通过 glTexImage2D 手动分配和更新数据
    内存占用较低(驱动优化存储)较高(显存占用明确)
    适用场景深度/模板测试、临时颜色缓冲需要重复访问数据的场景(如动态贴图)

RBO绑定到FBO时,只能通过glReadPixels读取像素数据,无法像纹理一样直接采样。若需要将渲染结果作为纹理使用(如滤镜链),选择纹理附件。而RBO更适合临时存储不需要复用的数据(如深度缓冲或者显示)。

1.1.2 离屏渲染

离屏渲染常常被作为iOS的面试内容,但接触了FBO会有更深入的理解。离屏渲染是指GPU将内容绘制到与屏幕显示无关的缓冲区(Offscreen Buffer),而不是直接渲染到当前屏幕的帧缓冲中。

  • 离屏渲染应用场景

    主动创建离屏FBO用于后处理(如模糊、滤镜)或多阶段渲染,如在音视频学习Demo中,实现如下效果(随意的测试效果)。

    人脸点图片.jpg

    使用配置如下,原图读入纹理-》画人脸点-》灰度-》面罩-》iOS CAEAGLLayer显示。在CAEAGLLayer显示的一层FBO绑定的RBO,属于当前屏幕渲染(有些资料上显示非0的FrameBuffer都是离屏渲染是不对的)。而前面的步骤FBO绑定的都是纹理,并且上一步的结果作为下一步的输入,都属于离屏渲染

    /// 这里可以配置滤镜
    pointsFilter->addTarget(grayFilter);
    grayFilter->addTarget(maskFilter);
    maskLink->input = pointsFilter;
    maskLink->output = maskFilter;
    [self.gpuView setFilterLinks:maskLink];
    
  • 离屏渲染误区

    iOS面试中经常被问到的是如何避免离屏渲染。离屏渲染需要额外创建缓冲区,而且需要切换上下文,比较耗费性能,所以需要避免自动触发的离屏渲染的场景,如阴影、遮罩、半透明等。

    离屏渲染流程.png

    先看一下为何需要自动触发离屏渲染,正常的渲染流程采用油画算法由远及近的渲染图层。但在一些情况下,如下图按钮圆角+图片(clipsToBounds=YES),或者组透明度(allowsGroupOpacity=YES)。油画算法无法先画完按钮和图片,再在原画布切割成圆角,(可以想象PS时借助图层才能完成)需要先在一个透明图层上处理完这些再渲染到当前FrameBuffer上,于是就是离屏渲染了。

    离屏渲染.jpg

    关于离屏渲染的场景,可以参考其他材料。这里想说的是一种误解

    • Core Graphics绘图

      • 把绘图任务交给了CPU
      • 简单的圆角绘制场景下,可以避免离屏渲染的额外开销,可能具有更高的性能,最好结合场景测试后再给结论。
      • 更合适的做法,使用异步线程处理,处理完再交给主线程。
      - (void)drawRect:(CGRect)rect {
          CGContextRef context = UIGraphicsGetCurrentContext();
          CGRect bounds = self.bounds;
          CGFloat cornerRadius = 10.0;
      
          // 创建一个圆角矩形路径
          UIBezierPath *roundedPath = [UIBezierPath bezierPathWithRoundedRect:bounds cornerRadius:cornerRadius];
          CGContextAddPath(context, roundedPath.CGPath);
      
          // 裁剪上下文
          CGContextClip(context);
          // 绘制内容
          [[UIColor redColor] setFill];
          CGContextFillRect(context, bounds);
        }
      
    • mask设置圆角

      • 需要额外framebuffer,会触发离屏渲染
      • 设置圆角时不会比自动触发离屏渲染性能好
      • 在处理复杂图形时,硬件加速更有优势,会比Core Graphics绘图有更好性能。
    // 创建一个遮罩图层
    CAShapeLayer *maskLayer = [CAShapeLayer layer];
    UIBezierPath *roundedPath = [UIBezierPath bezierPathWithRoundedRect:view.bounds cornerRadius:10.0];
    maskLayer.path = roundedPath.CGPath;
    
    // 设置遮罩图层
    view.layer.mask = maskLayer;
    
    • 光栅化(layer.shouldRasterize=YES
      • 系统会把图层内容渲染成一张位图并缓存起来,本身就是离屏渲染
      • 如果该图层的内容没有发生变化,系统就可以直接使用缓存中的位图,从而大大提高了渲染效率。
      • 如果图层内容频繁变化,每次变化都需要重新进行离屏渲染,带来更多的性能开销。

1.2 基本图形

上一篇中介绍了基本流程和坐标系,这一小节说明绘制基本图形。

glDrawArrays(GL_TRIANGLES, 0, 6); // glDrawElements
// 展示缓冲区
[self.context presentRenderbuffer:GL_RENDERBUFFER];

相当于开始落笔绘画了,有下列笔法(移动端OpenGL ES仅支持三角形,OpenGL支持更多图形):

类型说明画线
GL_POINTS绘制独立的点image.png
GL_LINES每2个顶点组成一条线段image.png
GL_LINE_STRIP连续线段image.png
GL_LINE_LOOP闭合连续线段image.png
GL_TRIANGLES每3个顶点组成一个三角形image.png
GL_TRIANGLE_STRIP带状三角形,一条边共享image.png
GL_TRIANGLE_FAN扇形三角形,共享中线点image.png

1.3 纹理

纹理可以理解为存储在显存中图像,可以通过UV坐标映射到3D模型的表面,为图形渲染带来了丰富的细节和真实感。

上一篇介绍过纹理坐标,原点在左下,但图像存储时是以左上为(0,0),所以一般会遇到上下颠倒的问题,可以在shader中处理,也可以把纹理坐标倒过来放。

坐标输入.jpg

1.3.1 纹理的创建

// 纹理加载
GLuint texture;
glGenTextures(1, &texture);
// 绑定到当前活跃的纹理单元(Active Texture Unit中的指定纹理目标(如 `GL_TEXTURE_2D`)
glBindTexture(GL_TEXTURE_2D, texture);
// 设置环绕模式,超过边界时的表现
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
// 设置过滤模式,放大缩小的表现
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
  • 环绕模式

    模式行为示意图
    GL_REPEAT平铺重复▭▭▭▭
    GL_CLAMP_TO_EDGE边缘拉伸▭→边缘延伸
    GL_MIRRORED_REPEAT镜像重复▭8▭▭8▭
  • 过滤模式

    模式描述适用场景
    GL_NEAREST最近邻采样,保留锐利边缘像素风格
    GL_LINEAR双线性插值,平滑过渡常规3D模型
    GL_LINEAR_MIPMAP_LINEAR三线性过滤高质量渲染
  • 多级渐远纹理(Mipmap) Mipmap是一系列不同分辨率的纹理图像,每个图像的尺寸是前一个的一半。例如用于不同距离下采样,可以减少锯齿和闪烁现象,同时还能优化性能。

    minmap.jpg

使用也特别简单,在创建完一个纹理后调用glGenerateMipmap,OpenGL就会承担接下来的所有工作了。

glGenerateMipmap(GL_TEXTURE_2D);

纹理数据的填充,可以直接从CPU copy,也可以如iOS相机共享内存,还可以绑定FrameBuffer,作为画布填充中间内容。下文会讲图片到纹理和相机帧到纹理。

1.3.1 图片读取(通用)

Texture的数据填充,其实就是从CPU的spriteData Copy数据即可。

glTexImage2D(
    GL_TEXTURE_2D,    // 目标
    0,                // Mipmap级别(0为基级)
    GL_RGBA,          // 内部存储格式
    width, height,    // 纹理尺寸
    0,                // 历史遗留参数
    format,           // 数据格式
    GL_UNSIGNED_BYTE, // 数据类型
    data              // 像素数据指针
);

但对于像素指针data图片通常需要一下过程,这一步骤实际上是在给图片解码转换为位图PNG格式为无损压缩,JPEG为有损压缩,过程和视频编码的帧内编码很类似。

解码这个过程也很耗时的,所以iOS的一些框架,如 YYImageSDImage,在下载成功后也会进行如下类似步骤,目的就是在异步解码,从而在显示图片时更加流畅。

//1、将 UIImage 转换为 CGImageRef
CGImageRef spriteImage = [UIImage imageNamed:fileName].CGImage;
//判断图片是否获取成功
if (!spriteImage) {
    exit(1);
}
// 读取图片的大小,宽和高
size_t width = CGImageGetWidth(spriteImage);
size_t height = CGImageGetHeight(spriteImage);
// 获取图片字节数 宽*高*4(RGBA)
GLubyte * spriteData = (GLubyte *) calloc(width * height * 4, sizeof(GLubyte));
// 创建上下文
CGContextRef spriteContext = CGBitmapContextCreate(spriteData, width, height, 8, width*4,CGImageGetColorSpace(spriteImage), kCGImageAlphaPremultipliedLast);
// 在CGContextRef上--> 将图片绘制出来
CGRect rect = CGRectMake(0, 0, width, height);
// 使用默认方式绘制
CGContextDrawImage(spriteContext, rect, spriteImage);
// 画图完毕就释放上下文
CGContextRelease(spriteContext);

1.3.2 iOS相机读取(共享内存)

在看GPUImage代码时,会发现纹理创建是通过以下方式,Core Video实现了CPU和GPU共享内存的方式可以实现高效零拷贝操作:

if ([GPUImageContext supportsFastTextureUpload]) {
  // CVOpenGLESTextureCacheCreateTextureFromImage
} else {
  // generateTexture
}

主要步骤包括:

  • 创建Core Video纹理缓存
CVOpenGLESTextureCacheRef textureCache;
CVReturn err = CVOpenGLESTextureCacheCreate(
    kCFAllocatorDefault,
    NULL,
    glContext,
    NULL,
    &textureCache
);
  • 创建纹理(yTexName),pixelBuffer在相机部分会有说明
CVOpenGLESTextureRef yTexture;
CVReturn yRet = CVOpenGLESTextureCacheCreateTextureFromImage(
    kCFAllocatorDefault,
    textureCache,
    pixelBuffer,
    NULL,
    GL_TEXTURE_2D,
    GL_LUMINANCE,
    (GLsizei)CVPixelBufferGetWidth(pixelBuffer),
    (GLsizei)CVPixelBufferGetHeight(pixelBuffer),
    GL_LUMINANCE,
    GL_UNSIGNED_BYTE,
    0,
    &yTexture
);
GLuint yTexName = CVOpenGLESTextureGetName(yTexture);

// 获取UV纹理