从0打造一个GPUImage(5) -- GPUImage架构详解1

819 阅读8分钟
原文链接: zhuanlan.zhihu.com

从0打造一个GPUImage(5)

知己知彼

GPUImage2的框架

为啥不说GPUImage1是因为GPUImage1写的太啰嗦了。当然主要是Objective-C这门语言的问题。
相比较而言,GPUImage2的代码几乎是1的一半。而且结构非常清晰。


框架概述

整个GPUImage2大概分为4部分。



Base

Base主要一些基础工具类。
包含。
1. ShaderProgram
用来compiler和attach我们的vertex shader和fragment shader。看过前几篇文章的应该知道这个类的作用。
2. SerialDispatch
一个protocol

public protocol SerialDispatch {
    var serialDispatchQueue:DispatchQueue { get }
    var dispatchQueueKey:DispatchSpecificKey<Int> { get }
    func makeCurrentContext()
}

主要是定义了一个串行queue和这个队列的key,还有一个方法。

它的extension封装了一些GCD的方法。比如runOperationSynchronously,其实就是

public func runOperationSynchronously(_ operation:() -> ()) {
        // TODO: Verify this works as intended
        if (DispatchQueue.getSpecific(key:self.dispatchQueueKey) == 81) {
            operation()
        } else {
            self.serialDispatchQueue.sync {
                self.makeCurrentContext()
                operation()
            }
        }
    }

为了防止deadlock使用了getspecific来防止在同一个队列使用sync。

3.ShaderUniformSettings
作者封装的用来给uniform赋值的类。

看过之前的文章,大家应该知道,shader有3种类型修饰,attribute, varying, uniform.
attribute只能用在vertex shader中,一般用来传递顶点数据。由于GPUImage不做3D,所以attribute修饰的变量在GPUImage的shader几乎只有一种,就是我们绘制正方形的顶点数据。

varying用来在vertex shader和fragment shader之间传递数据。我们现在使用的只有一个,就是用来传递纹理坐标。

所以,在GPUImage中,用来修改图像属性的各种变量有且只有一种选择就是把图像的参数,例如亮度,对比度等设置为uniform类型。
实际上,uniform的官方定义就是
uniform变量是外部application程序传递给(vertex和fragment)shader的变量
所以,GPUImage的fragment shader中存在大量的uniform变量,为了方便获取和设置这些uniform变量,GPUImage的作者有理由创造这么一个类来管理。

我随便找几个GPUImage内置的fragment shader,大家可以看一下uniform变量的使用。



4.OpenGLContext
这个class是一个实现了上文提到的SerialDispatch协议的类。
并且由于跨平台的原因,在Mac和iOS目录中各有一个同名的OpenGLContext。
这个类主要包含一个EAGLContext和shader cache。
我刚看到这个类的时候在疑惑为什么不把它做成一个单例。因为理论上我们在整个流程不大可能用到第二个EAGLContext,实际上整个GPUIMage中确实只用到了一个OpenGLContext,在OpenGLContext_Shared中,我们能找到这样一句话.

在整个GPUImage流程中,我们也只用到这么一个。

但是我在Mac文件夹中的OpenGLContext类中我发现了一行注释。
// TODO: Figure out way to allow for multiple contexts for different GPUs
说明在针对Mac平台上有可能存在的双显卡(一般是一个主板集成显卡和一个AMD显卡),就可能需要设置两个context来分别对应GPU了。

5.OpenGLRendering

用来负责render 的类。其中最主要的一个方法就是
public func renderQuadWithShader(_ shader:ShaderProgram, uniformSettings:ShaderUniformSettings? = nil, vertices:[GLfloat], inputTextures:[InputTextureProperties])

之前的章节我介绍过,绘制图片的方法是把图片绘制到一个正方形上。
回顾下之前的方法。

- (void)drawTrangle {
    [self activeTexture];
    UIImage *image = [UIImage imageNamed:@"wuyanzu.jpg"];
    
    const GLfloat vertices[] = {
        -1, -1, 0,   //左下
        1,  -1, 0,   //右下
        -1, 1,  0,   //左上
        1,  1,  0 }; //右上
    glEnableVertexAttribArray(_positionSlot);
    glVertexAttribPointer(_positionSlot, 3, GL_FLOAT, GL_FALSE, 0, vertices);
    
    // normal
    static const GLfloat coords[] = {
        0, 0,
        1, 0,
        0, 1,
        1, 1
    };

    glEnableVertexAttribArray(_textureCoordSlot);
    glVertexAttribPointer(_textureCoordSlot, 2, GL_FLOAT, GL_FALSE, 0, coords);
    
    static const GLfloat colors[] = {
        1, 0, 0, 1,
        1, 0, 0, 1,
        1, 0, 0, 1,
        1, 0, 0, 1
    };
    
    glEnableVertexAttribArray(_colorSlot);
    glVertexAttribPointer(_colorSlot, 4, GL_FLOAT, GL_FALSE, 0, colors);
    
    glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
    [_eaglContext presentRenderbuffer:GL_RENDERBUFFER];

再看看GPUImage的这个方法。基本上是一个意思。

public func renderQuadWithShader(_ shader:ShaderProgram, uniformSettings:ShaderUniformSettings? = nil, vertices:[GLfloat], inputTextures:[InputTextureProperties]) {
    sharedImageProcessingContext.makeCurrentContext()
    shader.use()
    uniformSettings?.restoreShaderSettings(shader)

    guard let positionAttribute = shader.attributeIndex("position") else { fatalError("A position attribute was missing from the shader program during rendering.") }
    glVertexAttribPointer(positionAttribute, 2, GLenum(GL_FLOAT), 0, 0, vertices)

    for (index, inputTexture) in inputTextures.enumerated() {
        if let textureCoordinateAttribute = shader.attributeIndex("inputTextureCoordinate".withNonZeroSuffix(index)) {
            glVertexAttribPointer(textureCoordinateAttribute, 2, GLenum(GL_FLOAT), 0, 0, inputTexture.textureCoordinates)
        } else if (index == 0) {
            fatalError("The required attribute named inputTextureCoordinate was missing from the shader program during rendering.")
        }
        
        glActiveTexture(textureUnitForIndex(index))
        glBindTexture(GLenum(GL_TEXTURE_2D), inputTexture.texture)

        shader.setValue(GLint(index), forUniform:"inputImageTexture".withNonZeroSuffix(index))
    }
    
    glDrawArrays(GLenum(GL_TRIANGLE_STRIP), 0, 4)
    
    for (index, _) in inputTextures.enumerated() {
        glActiveTexture(textureUnitForIndex(index))
        glBindTexture(GLenum(GL_TEXTURE_2D), 0)
    }
}

在这里详细介绍一下这个函数的意思。

  1. sharedImageProcessingContext.makeCurrentContext() 设置EAGLContext的currentContext是我声明的一个context。
  2. shader.use() 指定使用的shader program
  3. uniformSettings?.restoreShaderSettings(shader) 这个uniformSetting我们前面也说过,就是储存你对fragment shader里一些属性的值。在这里的时候,我们调用这个方法就是将你对shader的改动真正的应用到shader里。其实你点开这个方法就能见到。
  4. guard let positionAttribute = shader.attributeIndex("position") else { fatalError("A position attribute was missing from the shader program during rendering.") }
    glVertexAttribPointer(positionAttribute, 2, GLenum(GL_FLOAT), 0, 0, vertices)

    这两句就不用多说了。给veretx shader的顶点属性赋值,检测如果vertex shader里没有position这个attribute的话直接报错。
  5. 接下来是一个for循环。
for (index, inputTexture) in inputTextures.enumerated() {
        if let textureCoordinateAttribute = shader.attributeIndex("inputTextureCoordinate".withNonZeroSuffix(index)) {
            glVertexAttribPointer(textureCoordinateAttribute, 2, GLenum(GL_FLOAT), 0, 0, inputTexture.textureCoordinates)
        } else if (index == 0) {
            fatalError("The required attribute named inputTextureCoordinate was missing from the shader program during rendering.")
        }
        
        glActiveTexture(textureUnitForIndex(index))
        glBindTexture(GLenum(GL_TEXTURE_2D), inputTexture.texture)

        shader.setValue(GLint(index), forUniform:"inputImageTexture".withNonZeroSuffix(index))
    }

概括一下就是,设置fragment里的texture和textureCoordinate。

不理解为啥是个for循环的话。因为GPUImage最多支持5个texture。

看Operations目录的这几个文件,研究一下你就懂了。


剩下的就是一些util方法了。不细细介绍了。

6.Pipeline
此类功能非常多,阅读起来也比较困难,但是,这个类是整个GPUImage的核心,可以这么说,如果看懂了ImageConsumerImageSource这两个protocol,理解整个GPUImage对你来说再也没有任何困难。
阅读之前,我们需要提两个问题

问题1, 如何动态修改texture并且显示在framebuffer上?

在之前的篇章,我们虽然使用了shader,也知道怎么更改shader的属性,并且成功的将图片显示在了屏幕上。
但是我们没有触及一个核心,如何动态修改shader并实时渲染。

今天我们来解决这个问题。

首先打开之前的demo。
在storyboard里为vc添加一个UISlider。


设置slider的范围为-1 - 1
并且,拷贝GPUImage的其中一个滤镜Brightness_GL.fsh到我们的项目里。
这个shader的内容如下。

precision mediump float;
uniform sampler2D u_Texture;
varying vec2 v_TexCoordOut;
uniform float brightness;

void main()
{
    vec4 textureColor = texture2D(u_Texture, v_TexCoordOut);
    
    gl_FragColor = vec4((textureColor.rgb + vec3(brightness)), textureColor.w);
}

简单来说,就通过更改brightness这个参数来更改整张图片的亮度。

然后给我们的UISlider增加一个action

- (IBAction)valueChanged:(UISlider *)sender {
    glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);
    
    [shaderCompiler prepareToDraw];
    
    glUniform1f(_brightness, sender.value);
    [self activeTexture];
    
    [self drawTrangle];
}

解释一下。
首先,我们执行了清屏。
然后调用[shaderCompiler prepareToDraw]; 其实就是使用我们当前的shader program.
prepareToDraw的方法体是。

- (void)prepareToDraw {
    glUseProgram(_programHandle);
}

然后,我们需要更改shader中的uniform类型的变量brightness的值。
brightness的地址,我们已经通过_brightness = [shaderCompiler uniformIndex:@"brightness"];获取到并且赋给了_brightness这个变量。
所以直接调用glUniform1f(_brightness, sender.value);来更改他。
然后传递我们的纹理。最后重绘即可。

效果如下。




问题2: 如何把我们的纹理导出成为一个图片文件?

核心是使用一个叫做glReadPixels方法。
方法如下。

- (void)getImageFromBuffe:(int)width withHeight:(int)height {
    GLint x = 0, y = 0;
    NSInteger dataLength = width * height * 4;
    GLubyte *data = (GLubyte*)malloc(dataLength * sizeof(GLubyte));
    
    glPixelStorei(GL_PACK_ALIGNMENT, 4);
    glReadPixels(x, y, width, height, GL_RGBA, GL_UNSIGNED_BYTE, data);
    
    CGDataProviderRef ref = CGDataProviderCreateWithData(NULL, data, dataLength, NULL);
    CGColorSpaceRef colorspace = CGColorSpaceCreateDeviceRGB();
    CGImageRef iref = CGImageCreate(width, height, 8, 32, width * 4, colorspace, kCGBitmapByteOrder32Big | kCGImageAlphaPremultipliedLast,
                                    ref, NULL, true, kCGRenderingIntentDefault);
    
    
    UIGraphicsBeginImageContext(CGSizeMake(width, height));
    CGContextRef cgcontext = UIGraphicsGetCurrentContext();
    CGContextSetBlendMode(cgcontext, kCGBlendModeCopy);
    CGContextDrawImage(cgcontext, CGRectMake(0.0, 0.0, width, height), iref);
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    
    free(data);
    CFRelease(ref);
    CFRelease(colorspace);
    CGImageRelease(iref);
}

解释一下就是,glReadPiexel会读取framebuffer上的pixel信息,然后储存在data里。
我们使用CoreGraphics从data生成UIImage即可。

我们打个断点测试一下图片。


其实这个图片是有问题的。
1. 图片上下有白边。
2. 图片大小不对。通过po image.size得到的图片大小是(width = 414, height = 736).其实是我们的屏幕大小。

为什么会发生这样的原因呢?
这里,需要再说一下framebuffer的结构了。



framebuffer本身是没有任何作用的,他必须挂载一些附件才能工作。比如我们之前挂在了一个GLRenderBuffer,所以framebuffer就可以在屏幕上显示我们渲染的结果。

这时候我们读取framebuffer的像素实际上是读取renderbuffer里的东西。那么renderbuffer的大小和屏幕大小相同,所以我们获取的图片大小和内容就是当前屏幕的内容。

那么怎么获取原始图片分辨率的经过处理的图片呢?

这里就需要使用离屏渲染的概念了。
我们只需要再创建一个offscreen framebuffer,然后让他装载一个texture。最后,我们使用这个离屏的framebuffer来处理我们的图片。

过程如下。

  1. 创建离屏buffer。
- (void)createOffscreenBuffer:(UIImage *)image {
    glGenFramebuffers(1, &_offscreenFramebuffer);
    glBindFramebuffer(GL_FRAMEBUFFER, _offscreenFramebuffer);
    
    //Create the texture
    GLuint texture;
    glGenTextures(1, &texture);
    glBindTexture(GL_TEXTURE_2D, texture);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA,  image.size.width, image.size.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    
    //Bind the texture to your FBO
    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
    
    //Test if everything failed
    GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
    if(status != GL_FRAMEBUFFER_COMPLETE) {
        printf("failed to make complete framebuffer object %x", status);
    }
    glBindFramebuffer(GL_FRAMEBUFFER, 0);
    glBindTexture(GL_TEXTURE_2D, 0);
}

在这里为什么glTexImage2D传递的纹理数据是NULL呢?
因为framebuffer正常工作的前提是至少需要一个renderbuffer或者texture,为了能够让我们的离屏buffer正常工作,我们需要传入一个空的texture。

然后,在获取图片的时候。

- (IBAction)getImage:(id)sender {
    glBindFramebuffer(GL_FRAMEBUFFER, _offscreenFramebuffer);
    glViewport(0, 0, processImage.size.width, processImage.size.height);
    glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    [shaderCompiler prepareToDraw];

    glUniform1f(_brightness, _brightSlider.value);
    [self activeTexture];

    [self drawRaw];
    
    [self getImageFromBuffe:width withHeight:height];
}

我们需要把当前framebuffer转换为我们的离屏buffer。
设置viewport。传递数据,最后绘制。

然后就可以得到一个原始分辨率的处理过的图片了。

Pipeline

好了现在说会GPUImage的pipeline。
首先,为什么GPUImage会设置pipeline?
因为GPUImage允许下面这种用法。

let testImage = UIImage(named:"WID-small.jpg")!
let toonFilter = SmoothToonFilter()
let luminanceFilter = Luminance()
let filteredImage = testImage.filterWithPipeline{input, output in
    input --> toonFilter --> luminanceFilter --> output
}

就是GPUImage允许串联多个滤镜直接生成一个最终结果的图片。

所以pipeline的核心就是,如何串联多个filter?

那么解决方案有两个。

大家可以思考一下,有哪两种解决方案?
我们下期详细介绍GPUImage的管线(pipeline)方案。