GPUImage中的OpenGL
因为项目的要求所以先前研究了很久的OpenGL,用来完成超大图片的实现,本身用到OpenGL的部分有限,毕竟不涉及3D和光影等部分,但是自己初学OpenGL的时候可是花了不少力气,一整套从基础的三角到视角转换到高级特性全部溜了一遍,不过网上这样的教程实在是太多所以也没有专程写下来的必要=。=(主要是当时学的时候太弱智没有记录下来学习心得,现在再翻手写笔记太麻烦了)。
正好最近想看看滤镜和图片处理功能中OpenGL的使用,权当复习一下了,所以找到久负盛名的开源框架GPUImage,稍微看一下内容,然后整理一下OpenGL比较基础的使用方法把。
GPUImage介绍
GPUImage是iOS上一个基于OpenGL进行图片处理的开源框架,由于自带各种滤镜并且能接受很多种类的输入数据,所以不管是效率还是泛用性都是iOS上进行图片处理工作的优先选择。
以图片处理为例,GPUImage让用户不需要进行非常繁杂的OpenGL上下文和指令输入,更别提还有复杂的shader渲染计算,从图片数据转为OpenGL可以识别的数据,进入渲染管线最终成为从framebuffer取出显示在屏幕上,这些操作全部都交由GPUImage框架来完成。
最有意思的是,调用GPUImage的各种组件的方法是一种链式的添加,比如获取图片,添加滤镜,输出图片所用的组件按照调用顺序一个个“挂”起来。看看例子:
|
|
非常简单明了的调用,获取图片,使用素描滤镜然后在新建的Imageview中显示出来。
GPUImage中包含了非常多的滤镜,超过100种,包括对图片和视频进行处理的各种方案。当然没必要一个个细看清楚,我们可以从调用的角度来看一看进行一次图片处理的时候会调用什么方法。先看看框架中主要的几个部件:
|
|
其中最为重要的是GLProgram,GPUImageContext,GPUImageFramebuffer。第一个管理了OpenGL的调用,熟悉OpenGL的人都知道,想要使用OpenGL必须将其所有的渲染管线打包成一个program,这里就是封装了我们渲染图片所用的各种部件包括shader。第二个是上下文,也是OpenGL绘制图片时需要获取的信息。第三个FrameBuffer大家也应该不陌生,在管线中处理过的光栅化之后的图形数据最后会在frameBuffer中,等待后续处理然后直接作为显示的像素使用。
其他的后面可能会提到,比如处理输入的GPUImageVideoCamera,GPUImageStillCamera,GPUimagePicture等。
大致的流程是这样的:
获取数据
我们知道OpenGL用作处理图片的工作方式就是将图像信息转化为OpenGL的纹理,然后对每个像素点进行计算处理。那么首先要完成的工作就是采集数据了。考虑到GPUImage并不只是处理静态图片,还包括视频,纯纹理,摄像头数据等不同来源不同格式的数据,所以光从数据接口就有很多不同的形式,处理静态图片的GPUImagePicture,处理视频文件的GPUImageMovie,调用摄像头处理照片和视频的GPUImageStillCamera和GPUImageVideoCamera等。传递数据
GPUImage的图像渲染过程是一个链式的渲染过程,一个阶段接在另一个阶段之后,不管中间有多少组件,最终逻辑上都是 输入组件->中间处理->最终输出 这么三个部分。而每个Filter中间,都是依靠outputFrameBuffer和inputFrameBuffer来完成数据传递的。一个Filter通过GPUImageInput协议来完成数据的传入,而GPUImageOutput来完成数据向后传递的实现。处理数据
当一个Filter获取到待处理的数据之后,会按照这个流程来完成渲染:(1)向frameBufferCache申请一个outputFrameBuffer (2)将申请得到的outputFrameBuffer激活并设为渲染对象 (3)glClear清空画布信息 (4)传入顶点并设置输入纹理及其坐标 (5)调用绘制指令。 至于shader是各个filter自己封装好的,并不需要使用者自己调用。比如我们调用GPUImageFilter默认的shader,其实什么渲染效果都没有,就是维持原图状态。输出数据
主要是GPUImageView和GPUImageMovieWriter,其实就是将这些输出模块添加到滤镜链中等着处理完了再显示就行了。
大致就是这样,其实和OpenGL渲染本质的状态机是差不多的,其内核只管从外界拿来数据然后按照固定的流程执行渲染最后再给出最终结果,无非是根据渲染的指令不同而改变一下渲染的具体设置,但是OpenGL本身是不断循环往复这个渲染流水线,可以说是无限循环了。
具体例子分析
输入数据处理
上面说了这么多,对于GPUImage的流程也有一定的了解,现在我们按照一开始的例子来具体分析每一步的具体代码:
先看第一步输入数据:
上面看起来一两百行的代码非常麻烦,但是其实逻辑非常清晰:(1)首先判断图片的大小是否合法,如果合法就什么都不做,如果不合法就改变至可接受的范围 (2)判断是不是需要重新计算像素数据,有很多原因需要重新计算数据,比如图片大小需要改变、数据格式不正确等原因 (3)如果需要重新绘制就重新绘制吧,不管怎么样得到新数据还是使用原数据,先看一下有没有premultiplied alpha,对应这个操作需要进行一些还原计算(去除RGB通道中alpha值的影响) (4)最后是OpenGL的例行操作,将像素数据作为texture交给OpenGL管线。
具体的重要步骤已经用注释的形式添加在了代码中,可以细看。
其中有一些需要说明的地方:
- OpenGL要求纹理的宽和高都是2的整数幂,但是事实上我们经常会遇到纹理不满足这种尺寸的情况,有很多解决办法,比如取一个满足要求的背景,比如256256,然后将我们200200的的图片放到上面,这样读取纹理的时候的尺寸是满足要求的,但是实际使用只需要这有效的一部分就行了;也有atlas texture这种操作,将多个素材拼到一起凑成一个大的纹理,调用的时候只调用其中的具体部分,其实本质和前面那个方法差不多,但是这样能省去很多单个读取纹理的时间,大大提高效率。在GPUImage中没有用到这么麻烦的操作,只是简单地区长和宽最接近的2整数次幂,然后作为纹理的大小就是了,这样图片有可能会比例变化,但是实际上使用纹理的时候也会拉伸,所以不至于视觉上很严重。
- mipmap:
mipmap是一种纹理处理技术,用来解决纹理调用中出现的闪烁和性能低效问题。闪烁是指当屏幕上被渲染物体的表面与它相对应的纹理显得非常小的时候会出现的一种现象。我们知道纹理的大小是固定的,但是实际使用起来由于会出现缩放裁剪等情况,我们可能并不需要原纹理那样大小的文件,所以mipmap技术就应运而生,加载纹理的时候不仅仅只加载当前这个纹理信息,而是进而加载一系列固定比例的缩小图,在OpenGL中被称为不同的层级。使用mipmap纹理的时候系统会根据实际需要的纹理大小从不同层级中选出最适合的一个。打个比方我们的原始纹理是256256,经过mipmap处理,每个层级的大小可能就是256256、128128、6464….调用这个纹理的时候可能根据实际大小需要会选择64*64的层级即可。 - premultiplied alpha:
我们知道通常使用的RGBA四个通道来表示一个像素,每个通道8位,比如红色60%透明度就是(255,0,0,153)或者说用正规浮点数记录alpha为(255,0,0,0.6),前三个代表三个颜色通道的值,alpha则是透明度,透明度是进行颜色混合的关键因素,假如一个像素的透明度是1.0,那么我们是看不到这个像素后面的颜色数据的,但是如果alpha<1.0,就要和后面的像素进行混合,比例分别是alpha和(1-alpha)。premultiplied alpha会让RGB三个通道提前乘以透明度,比如这里的(255,0,0,0.6)如果经过premultiplied alpha处理后就会是(153,0.,0,0.6)。这样的操作能让纹理进行texture filting(插值时的权重会出现问题),但是也让颜色数据变得更不直观。
回到代码中,其实做的非常简单,如果使用了premultiplied alpha,那么就分别将R、G、B、A提取出来然后还原本来的R、G、B,最后还原成没有经过premultiplied alpha处理的原像素数据。
后面还有OpenGL中绑定数据的方法,觉得需要结合OpenGL的用法来说,后面再细说。
Filter原型:GPUImageFilter
接下来看filter的原理,由于filter的种类太多,我们就从中挑选一个sketch filter来研究好了,在此之前,先看看所有filter共同的父类:GPUImageFilter。
看看代码中的注释说明:
GPUImage’s base filter class:
Filters and other subsequent elements in the chain conform to the GPUImageInput protocol, which lets them take in the supplied or processed texture from the previous link in the chain and do something with it. Objects one step further down the chain are considered targets, and processing can be branched by adding multiple targets to a single output or filter.
解释一下就是GPUImage最基本的filter类(什么渲染效果都没有),filters或者其他渲染链上的元素遵从GPUImageInput协议,让它们可以从链的前一个组件中获取数据。接在后面的部件称为target,也可以通过一次添加多个target完成分支渲染。
而GPUImageFilter又继承自GPUImageOutput,这个类的职责就是(1)维护一个outputFramebuffer,准备交给渲染链后面的组件 (2)通过GPUImageInput协议来从前面的组件接收数据 (3)记录下后面的组件作为自己的target (4)提供了不同线程上运行渲染片段的接口
基本上就是作为一个组件的基本逻辑,在这个类的基础上我们就可以实现链式渲染。
回到GPUImageFilter,由于是GPUImageOutput的子类,所以完整继承了上面我们说的这些作为渲染组件的特性。看一看其本身的结构特点:
|
|
上面是使用OpenGL需要的两个字符串和结构体,我们之前说了,OpenGL识别数据是靠用户传入的纯数字节流和解析方法,并没有能够提供关于数据结构上的接口,所以我们为了方便坐标颜色等因素的计算必须自己先定下结构体,比如上面的GPUVector4就是一个4维的向量,其中元素的类型是GLfloat,而GPUMatrix4x4是一个4x4的矩阵。
两个字符串,一个是vertexShader,一个是fragmentShader。前者是OpenGL处理输入顶点的程序,后者是光栅化之后如何处理每个片元的程序,是OpenGL的核心概念,也是我们从顶点数据到最终能够在图像上看到渲染完成后的图片的关键点。这个基础的内容很多,就没必要细说了,简单而言,前者是如何处理顶点的计算法则,后者是处理颜色的法则。
|
|
以上是GPUImageFilter的实例变量,有一部分由于继承自GPUImageOutput所以也放到一起来看了。
可以看到有两个GPUImageFramebuffer类型的变量,firstInputFramebuffer和outputFramebuffer,从字面上可以理解这就是输入和输出的framebuffer数据。后面是OpenGL需要使用的各种句柄,如attribute,uniform这些参数是除顶点数据、纹理等数据之外从外界向shader输入出具体参数的方法。然后还有尺寸、方向的数据,以及作为chain中的组件的targets数组等需要的部件。
现在先看看整个GPUImageFilter中最重要的方法:
这是最基本的初始化方法,其他的初始化API也都是调用这个方法的基础上进行变化,其中GPUImageFilter本身的两个shader非常简单:
|
|
vertex shader需要三个参数,position也就是顶点的位置,由于一般这里都是处理纹理,顶点基本就等于“相框”的顶点,没什么复杂操作;然后是纹理的位置,然后将纹理的坐标传给fragment shader。在vertex shader程序的main函数里,也只是原封不动地将坐标交给fragment shader,啥也没干。
而fragment shader,接受textureCoordinate作为参数,并且还有一个inputImageTexture作为uniform变量,也就是实际的纹理数据,然后再main函数里将纹理“贴”到固定的矩形里就行了,也基本上是什么都没做。
后面的很多各种Filter,基本上都是用的这么一个vertex shader,因为只要有个“相框”就行了,不需要复杂的位置视角变换;而不同的是各自如何处理颜色的fragment shader会不一样。
这样我们就完成了一个最基本的GPUImageFilter的初始化。
但是我们实际使用的时候并不会直接调用GPUImageFilter,毕竟它只是提供了Filter的原型,自己并没有实际的图形操作。例子中我们调用的是GPUImageSketchFilter,那么就一点点来看GPUImage是怎么将原型filter扩展成多种多样的特效filter的过程的。
特定filter的实现
下图是GPUImageSketchFilter的继承关系:
我们已经讲过了GPUImageOutput和GPUImageFilter,那么一步步看看每一个类都添加了什么功能吧:
- GPUImageTwoPassFilter:
我们先看看其实例变量:1234567GPUImageFramebuffer *secondOutputFramebuffer;GLProgram *secondFilterProgram;GLint secondFilterPositionAttribute, secondFilterTextureCoordinateAttribute;GLint secondFilterInputTextureUniform, secondFilterInputTextureUniform2;NSMutableDictionary *secondProgramUniformStateRestorationBlocks;
很明显这个类就像它的名字一样,在原有的一套inputFramebuffer+outputFramebuffer的基础上添加了一个新的secondOutputFramebuffer。也就是说一组input数据输入之后,有两套完全不同的pipeline来处理这个数据,最后从两个framebuffer分别输出,名副其实的“two pass”。
知道了这个前提,那么初始化方法其实我们很好猜到到底干了些啥:
|
|
不用看这么多内容,其实很简单,第一步调用父类的初始化方法产生一套pipeline,对应的framebuffer,第二步按照同样的流程再生成一个pipeline(全部都是second前缀),需要说明的是第二套pipeline的vertex shader & fragment shader是需要调用初始化方法的时候当做参数传入的。
- GPUImageSobelEdgeDetectionFilter:
这里说明一下SobelEdgeDetection是一种边缘检测算法,那么很清楚这个类就是加入了这一边缘检测处理的子类。至于说为什么这里会用到这个算法,因为我们调用的滤镜就是将图像素描化的效果,所以要进行边缘检测并且改变图片的灰度表达。具体的计算方法不细讲了,这里看看初始化的方法和fragment shader。12345678910111213141516171819- (id)initWithFragmentShaderFromString:(NSString *)fragmentShaderString;{// Do a luminance pass first to reduce the calculations performed at each fragment in the edge detection phase// 这里直接调用了父类的初始化方法,第二个fragment shader做为参数交给初始化方法if (!(self = [super initWithFirstStageVertexShaderFromString:kGPUImageVertexShaderString firstStageFragmentShaderFromString:kGPUImageLuminanceFragmentShaderString secondStageVertexShaderFromString:kGPUImageNearbyTexelSamplingVertexShaderString secondStageFragmentShaderFromString:fragmentShaderString])){return nil;}hasOverriddenImageSizeFactor = NO;texelWidthUniform = [secondFilterProgram uniformIndex:@"texelWidth"];texelHeightUniform = [secondFilterProgram uniformIndex:@"texelHeight"];edgeStrengthUniform = [secondFilterProgram uniformIndex:@"edgeStrength"];self.edgeStrength = 1.0;return self;}
没什么好说的,看看fragmentshader:
sobel边缘检测是一个根据目标与其周围像素的计算方法,利用查分算子进行纵向和横向的卷积计算灰度值,最后得出目标像素和周围点的差别,如果梯度大于某个阈值就认为是边缘点。这个算法精确度不是很高,但是在不要求精确度的情况下确实简单实用。
细节计算就不说明了。
- GPUImageSketchFilter:
这是最终封装好给外接调用的filter,点进去看发现其实初始化也只是封装好了父类的初始化方法而已,使用了自己的fragment shader进行封装。12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849NSString *const kGPUImageSketchFragmentShaderString = SHADER_STRING(precision mediump float;varying vec2 textureCoordinate;varying vec2 leftTextureCoordinate;varying vec2 rightTextureCoordinate;varying vec2 topTextureCoordinate;varying vec2 topLeftTextureCoordinate;varying vec2 topRightTextureCoordinate;varying vec2 bottomTextureCoordinate;varying vec2 bottomLeftTextureCoordinate;varying vec2 bottomRightTextureCoordinate;uniform float edgeStrength;uniform sampler2D inputImageTexture;void main(){float bottomLeftIntensity = texture2D(inputImageTexture, bottomLeftTextureCoordinate).r;float topRightIntensity = texture2D(inputImageTexture, topRightTextureCoordinate).r;float topLeftIntensity = texture2D(inputImageTexture, topLeftTextureCoordinate).r;float bottomRightIntensity = texture2D(inputImageTexture, bottomRightTextureCoordinate).r;float leftIntensity = texture2D(inputImageTexture, leftTextureCoordinate).r;float rightIntensity = texture2D(inputImageTexture, rightTextureCoordinate).r;float bottomIntensity = texture2D(inputImageTexture, bottomTextureCoordinate).r;float topIntensity = texture2D(inputImageTexture, topTextureCoordinate).r;float h = -topLeftIntensity - 2.0 * topIntensity - topRightIntensity + bottomLeftIntensity + 2.0 * bottomIntensity + bottomRightIntensity;float v = -bottomLeftIntensity - 2.0 * leftIntensity - topLeftIntensity + bottomRightIntensity + 2.0 * rightIntensity + topRightIntensity;// 也就是这里有差别float mag = 1.0 - (length(vec2(h, v)) * edgeStrength);gl_FragColor = vec4(vec3(mag), 1.0);});- (id)init;{if (!(self = [self initWithFragmentShaderFromString:kGPUImageSketchFragmentShaderString])){return nil;}return self;}
仔细观察的话就能发现,其实这个shader计算每个像素颜色的方法和我们在其父类SobelEdgeDetectiveFilter中看到的是基本相同的,只有最后决定颜色的时候是反过来用1减去本来计算出来的各通路的值的。
至此,我们例子中的初始化滤镜组件的部分就完成了。简单来说就是在最原始的GPUImageFilter的基础上再加一组pipeline,然后按照特定的fragment shader来输出数据。
不过这个例子只是GPUImage使用方式中的一种,只针对静态图片的滤镜是这样,别的情况下比如是对视频进行逐帧操作或者是拍下照片等,重点可能就不在fragment shader上了。限于篇幅没法一次说明那么多内容。
显示容器GPUImageView
GPUImageView结构上很简单,就是遵循了GPUImageInput协议的UIImageView。严格来说其在图片处理方面和前面介绍的filter组件是一样的,接受input数据之后创建自己的一套渲染pipeline,包括vertex shader & fragment shader,而这里使用的图片处理仅仅只负责显示出来的图片的角度,而不管光影颜色等复杂因素。初始化方法就不看了,和前面提到的pipeline的初始化是差不多的。这个GPUImageView要说和前面的组件有什么不一样的话可能就只有它将framebuffer直接显示在自己的视图上了吧。
最终启动渲染链
上面我们将整个渲染链中的组件,从数据读入、数据处理到图像展示的三个部分全部初始化完成并依次“组装”(addtarget)起来了。最后就调用链首的proccess方法。
这里我们调用的是GPUImagePicture的proccess方法:
setInputFramebuffer: atIndex:这个方法是GPUImageInput协议中需要实现的方法,由于我们的例子中使用的是GPUImageTwoInputFilter这个类,那么就看它里面对protocal的实现方法:
|
|
前面都是对收到的数据进行判断,可以看到最终调用的还是父类的方法,那我们回头去看:
|
|
好了,可以看到这里最终是调用了OpenGL的渲染管线,将最终渲染的结果放到framebuffer中,等待交给后面的组件使用。大部分都是固定管线操作。
|
|
完成渲染之后再调用后面的target,将数据传递下去。直到最终显示在GPUImageView上。就是这样一个逻辑。
总结
GPUImage作为一个用途广泛的图形视频处理框架,内容实在是太过丰富,对应不同的数据源还有不同的渲染、处理方法,搭配起来有非常复杂的变化。我们这里只是拿一个例子来分析了GPUImage对一个静态图片的渲染方法,其中涉及了部分OpenGL的绘制指令,包括program、vertex shader、fragment shdaer等内容,这些是OpenGL的基础内容,只有自己动手才能明白每一步有什么意义。在这个框架中我们能看到很清晰的“渲染链”的逻辑,完美地应对了复杂渲染的搭配。并且从最基本的GPUImageFilter不断拓展为各种效果的设计方法也值得学习。可以说这个框架的设计思路和结构,非常值得学习,里面调用OpenGL的指令也适合让初学者了解OpenGL渲染中每一步的意义。