16c480e0c3c2b9796020b258e0b24bec
OpenGL 知多少——抖音特效实战

OpenGL

OpenGL到底是什么。一般它被认为是一个API(Application Programming Interface, 应用程序编程接口),包含了一系列可以操作图形、图像的函数。然而,OpenGL本身并不是一个API,它仅仅是一个由Khronos组织制定并维护的规范(Specification)。

OpenGL怎么工作

OpenGL被当作客户端-服务器系统来实现的,应用程序是客户端,图形硬件厂商提供的OpenGL实现是服务器。客户端程序需要调用OpenGL的接口实现3D渲染,那么OpenGL命令和数据会缓存在内存中,在一定条件下,会将这些命令和数据通过CPU时钟发送到显存,在GPU的控制下,使用显存中的数据和命令,经过渲染管道完成图形的渲染,并将结果存入帧缓冲区中,帧缓冲区中的帧最终会被发送到显示器上,显示出结果。

渲染管道示意图


1、顶点处理:把一个单独的顶点输入顶点着色器,顶点着色器主要的目的是把3D坐标转为另一种3D坐标,OpenGL仅当3D坐标在3个轴(x、y和z)上都为-1.0到1.0的范围内时才处理它。所有在所谓的标准化设备坐标(Normalized Device Coordinates)范围内的坐标才会最终呈现在屏幕上(在这个范围以外的坐标都不会显示)。
2、图元装配:以顶点着色器的输出的顶点作为输入,装配成对应的图元(OpenGL ES 只支持三种图元,分别是顶点、线段、三角形,复杂的图形得通过渲染多个三角形来实现)。
3、处理图元:把上一步输出的图元输入到几何着色器,几何着色器把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状。
4、光栅化:把图元映射为最终屏幕上相应的像素,会裁剪超出视图外的像素(为了提高效率),最终生成片段。
5、处理片段:将片段输入到片段着色器,片段着色器的主要目的是计算一个像素的最终颜色(滤镜处理的地方)。
6、测试及混合:这个阶段检测片段的对应的深度值和模板值,用它们来判断这个像素是其它物体的前面还是后面,决定是否应该丢弃。这个阶段也会检查alpha值(alpha值定义了一个物体的透明度)并对物体进行混合(Blend)。

小概念解析

OpenGL上下文:OpenGL自身是一个巨大的状态机,一系列的变量描述OpenGL此刻应当如何运行,OpenGL的状态通常被称为OpenGL上下文,我们通常使用如下途径去更改OpenGL状态:设置选项,操作缓冲,最后,我们使用当前OpenGL上下文来渲染。
顶点数据:渲染图像需要的顶点坐标数组,例如渲染一个三角形需要三个顶点。
着色器:在GPU上运行的小程序,在图形渲染管线中快速处理你的数据,有三种类型:顶点着色器、几何着色器、片段着色器,我们只需要配置顶点和片段着色器就行了,几何着色器是可选的,通常使用它默认的着色器就行了。
纹理:纹理是一个2D图片(甚至也有1D和3D的纹理),它可以用来添加物体的细节。
帧缓冲:一个接收渲染结果的缓冲区,为 GPU 指定存储渲染结果的区域(纹理或渲染缓冲区),默认的帧缓冲是在创建渲染窗口的时候生成和配置的,我们可以自定义自己的帧缓冲。
OpenGL坐标:范围是 -1 ~ 1,是一个三维的坐标系,通常用 X、Y、Z 来表示。Z 轴的正方向指向屏幕外。

纹理坐标:纹理坐标系的范围是 0 ~ 1,是一个二维坐标系,原点左下,用来标明该从图像的哪个部分采样(使用纹理坐标获取纹理颜色)。

光栅化:决定哪些像素被集合图元覆盖的过程。

OpenGL编程

普通软件开发,属于CPU编程,CPU编程是串行编程,跟着代码顺序执行,代码到哪就执行到哪。而OpenGL编程,属于GPU编程,一系列的变量描述OpenGL此刻应当如何运行。
编程核心:顶点着色器、片段着色器

编程流

创建OpenGL上下文、渲染图层——>创建帧缓冲、渲染缓冲并绑定——>创建着色器程序,加载着色器代码并编译、链接——>配置窗口大小,启动着色器,加载顶点数据和纹理——>绘制渲染——>数据清理

抖音滤镜实践

为了将上面的内容更好的串联起来,今天我们来实践一个经典的抖音滤镜——抖动。

预准备

可以从https://github.com/caixindong/XDCaptureService 下载我们的预备工程,该工程提供最基本的相机捕获工程,打开/XDCaptureService/Example/XDCaptureService/XDViewController.m文件,这里就是我们书写相关实践代码的地方:

- (void)captureService:(XDCaptureService *)service getPreviewLayer:(AVCaptureVideoPreviewLayer *)previewLayer {
    if (previewLayer) {
        dispatch_async(dispatch_get_main_queue(), ^{
            [_contentView.layer addSublayer:previewLayer];
            previewLayer.frame = _contentView.bounds;
        });
    }
}
- (void)captureService:(XDCaptureService *)service outputSampleBuffer:(CMSampleBufferRef)sampleBuffer {

}

我们先将- (void)captureService:(XDCaptureService *)service getPreviewLayer:(AVCaptureVideoPreviewLayer *)previewLayer里的代码先注释掉,我们不使用AVFoundation默认提供的AVCaptureVideoPreviewLayer作为我们的预览视图,因为默认的预览视图不支持我们做进一步的滤镜处理,所以我们需要自己实现一个基于OpenGL实现的预览视图,以支持我们从渲染层面对每一个视频帧做滤镜处理。
- (void)captureService:(XDCaptureService *)service outputSampleBuffer:(CMSampleBufferRef)sampleBuffer这个方法用于回调相机捕获的视频帧数据,我们需要在里面做视频帧渲染相关逻辑。

OpenGL实战

我们先新建一个XDOpenGLPreView类作为我们OpenGL预览视图,提供一个方法用于渲染外部传进来的视频帧数据:

#import <UIKit/UIKit.h>
#import <CoreVideo/CoreVideo.h>

@interface XDOpenGLPreView : UIView

- (void)renderPixelBuffer:(CVPixelBufferRef)pixelbuffer;

@end

1、创建OpenGL上下文、渲染图层

+ (Class)layerClass {
    return [CAEAGLLayer class];
}

- (instancetype)initWithFrame:(CGRect)frame {
    if (self = [super initWithFrame:frame]) {
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 80000
        if ( [UIScreen instancesRespondToSelector:@selector(nativeScale)] )
        {
            self.contentScaleFactor = [UIScreen mainScreen].nativeScale;
        }
        else
#endif
        {
            self.contentScaleFactor = [UIScreen mainScreen].scale;
        }


        CAEAGLLayer *eaglLayer = (CAEAGLLayer *)self.layer;
        eaglLayer.opaque = YES;
        eaglLayer.drawableProperties = @{ kEAGLDrawablePropertyRetainedBacking : @(NO),
                                          kEAGLDrawablePropertyColorFormat : kEAGLColorFormatRGBA8 };


        _oglContext = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];
        if ( ! _oglContext ) {
            NSLog( @"Problem with OpenGL context." );
            return nil;
        }

    }
    return self;
}

CAEAGLLayer是CALayer的一个子类,用来显示任意的OpenGL图形,他作为我们的渲染图层。
kEAGLDrawablePropertyRetainedBacking :NO:配置渲染图层保留任何以前绘制的图像留作以后重用;
kEAGLDrawablePropertyColorFormat :kEAGLColorFormatRGBA8:配置渲染图层的像素格式;
kEAGLRenderingAPIOpenGLES2:指定使用OpenGL的版本是2.0;
2、初始化相关对象
包括帧缓冲、渲染缓冲、着色器程序的初始化,详解看代码中的注释

- (BOOL)initializeBuffers {
    BOOL success = YES;

    //关闭OpenGL功能:进行深度比较和更新深度缓冲
    glDisable( GL_DEPTH_TEST );

    ////////初始化帧缓冲
    //创建帧缓冲,为其申请一个id,赋值给_frameBuffer,1表示申请的个数。
    //帧缓冲本质上并不是一个独立的概念,它像是一个管理员,管理着手下的各个缓存,比如颜色缓存、模板缓存、深度缓存等等
    glGenFramebuffers( 1, &_frameBuffer );
    //绑定帧缓冲,绑定是为了告诉OpenGL在后面引用GL_FRAMEBUFFER引用_frameBuffer
    glBindFramebuffer( GL_FRAMEBUFFER, _frameBuffer );

    ////////初始化渲染缓冲
    //创建渲染缓冲,为其申请一个id,赋值给_colorBufferHandle,创建渲染缓存,1表示申请的个数
    glGenRenderbuffers( 1, &_colorBuffer );
    //绑定渲染缓冲,绑定是告诉OpenGL:我在后面引用GL_RENDERBUFFER的地方,其实是引用_colorRenderBuffer[在引用渲染缓存之前必须绑定当前渲染缓存对象,所以每次使用GL_RENDERBUFFER都要调一次这个]
    glBindRenderbuffer( GL_RENDERBUFFER, _colorBuffer );

    //把渲染缓冲绑定到渲染图层(CAEAGLLayer)上,并为它分配一个共享内存
    [_oglContext renderbufferStorage:GL_RENDERBUFFER fromDrawable:(CAEAGLLayer *)self.layer];

    //得到当前绑定的渲染缓存对象的一些参数。Target应该是GL_RENDERBUFFER,第二个参数是所要得到的参数名字。最后一个是指向存储返回值的整型量的指针
    glGetRenderbufferParameteriv( GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &_width );
    glGetRenderbufferParameteriv( GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &_height );

    //把 渲染缓存 添加到 帧缓存 的GL_COLOR_ATTACHMENT0附件上,这样整个数据流就串下来了,当数据渲染完之后会放进帧缓冲,实际上数据流向渲染缓冲,渲染缓冲的数据供给渲染图层展示。
    glFramebufferRenderbuffer( GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, _colorBuffer );
    if ( glCheckFramebufferStatus( GL_FRAMEBUFFER ) != GL_FRAMEBUFFER_COMPLETE ) {
        NSLog( @"Failure with framebuffer generation" );
        success = NO;
        goto bail;
    }

    //创建一个纹理缓冲,创建纹理的时候需要用到
    CVReturn err = CVOpenGLESTextureCacheCreate( kCFAllocatorDefault, NULL, _oglContext, NULL, &_textureCache );
    if ( err ) {
        NSLog( @"Error at CVOpenGLESTextureCacheCreate %d", err );
        success = NO;
        goto bail;
    }

    //创建着色器程序
    _program = glCreateProgram();

    //加载顶点着色器代码
    GLuint verShader = [self loadShader:GL_VERTEX_SHADER withString:kPassThruVertex];
    if (verShader == 0) {
        NSLog( @"Error at verShader");
        success = NO;
        goto bail;
    }

    //加载片段着色器代码
    GLuint fraShader = [self loadShader:GL_FRAGMENT_SHADER withString:kPassThruFragment];
    if (fraShader == 0) {
        NSLog( @"Error at fraShader");
        success = NO;
        goto bail;
    }

    //绑定顶点着色器和片段着色器到着色器程序上
    glAttachShader(_program, verShader);
    glAttachShader(_program, fraShader);

    //一定要在链接程序之前绑定属性,否则拿不到
    //第一种方法是通过glBindAttribLocation函数来实现索引和变量之间的对应关系。
    //首先,我们为shader中的每个顶点属性变量指定一个索引(一般从0开始)。
    //另一种方法则是在shader中直接指定,这是通过GLSL的关键词layout来是实现。为了实现这样的效果,我们需要更改之前的vertex shader的内容。
    glBindAttribLocation(_program, ATTRIB_VERTEX, "position");
    glBindAttribLocation(_program, ATTRIB_TEXTUREPOSITON, "texturecoordinate");

    //链接着色器程序
    glLinkProgram(_program);

    //链接完需要删除着色器
    glDeleteShader(verShader);
    glDeleteShader(fraShader);

    //获取着色器定义的属性,用于后面对里面的属性进行赋值。
    _frame = glGetUniformLocation(_program, "videoframe");

    _offset = glGetUniformLocation(_program, "offset");

    _uMvpMatrix = glGetUniformLocation(_program, "uMvpMatrix");

bail:
    if (!success) {
        [self reset];
    }
    return success;
}

先给个简单着色器代码示例:

//顶点着色器代码
static const char * kPassThruVertex = _STRINGIFY(

                                                 attribute vec4 position;
                                                 attribute mediump vec4 texturecoordinate;
                                                 varying mediump vec2 coordinate;

                                                 void main()
{
    gl_Position = position;
    coordinate = texturecoordinate.xy;
}

                                                 );

//片段着色器代码
static const char * kPassThruFragment = _STRINGIFY(

                                                   varying highp vec2 coordinate;
                                                   uniform sampler2D videoframe;

                                                   void main()
{
    gl_FragColor = texture2D(videoframe, coordinate);
}

                                                   );

着色器是类 C 语言写成,我先对一些关键字做一下简单的解释:
attribute:修饰符只存在于顶点着色器中,用于储存每个顶点信息的输入,比如这里定义了 Position 和 TextureCoords ,用于接收顶点的位置和纹理信息;
vec4 和 vec2:是数据类型,分别指四维向量和二维向量;
mat4:也是数据类型,指4*4矩阵;
varying:修饰符指顶点着色器的输出,同时也是片段着色器的输入,要求顶点着色器和片段着色器中都同时声明,并完全一致,则在片段着色器中可以获取到顶点着色器中的数据;

top Created with Sketch.