GPUImage 详解与框架源码分析

一、前言

这篇文章咱们来看一下cats-ossandroid-gpuimage。根据作者自己的解释,该项目的创意来自于IOS GPUImage。而GPU Image 的作用是利用 OpenGL 帮助我们实现图片初级处理,像高斯模糊,亮度,饱和度,白平衡等一些基础的滤镜。另外,GPU Image 帮助我们搭建好了一个框架,使得我们可以忽略使用 Open GL 过程中的各种繁锁的步骤,我们只要专注于自己的业务,通过继承 GPUImageFilter 或者组合其他的 Filter 就可以实现我们自己需要的功能。例如应用于人像美容处理的美颜,磨皮,美白等功能。那么,先来看看效果图吧。

原图
Invert滤镜

当然,受限于作者的水平以及精力,文章不会对算法的细节进行分析,而主要就是分析框架本身的架构以及逻辑。

二、基本应用

这里主要是对官文的一个简读。

1.依赖

当前的最新版本是 2.0.3

repositories {
    jcenter()
}

dependencies {
    implementation 'jp.co.cyberagent.android:gpuimage:2.0.3'
}

2.带预览界面

一般可以结合相机一起使用,以实现实时滤镜功能

@Override
public void onCreate(final Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity);

    Uri imageUri = ...;
    gpuImage = new GPUImage(this);
    gpuImage.setGLSurfaceView((GLSurfaceView) findViewById(R.id.surfaceView));
    // this loads image on the current thread, should be run in a thread
    gpuImage.setImage(imageUri); 
    gpuImage.setFilter(new GPUImageSepiaFilter());

    // Later when image should be saved saved:
    gpuImage.saveToPictures("GPUImage", "ImageWithFilter.jpg", null);
}

3.使用GPUImageView

GPUImageView 继承自 FrameLayout,其他就主要就是个帮助类,帮助我们集成使用 GpuImageFilter 和 SurfaceView/TextureView
xml

<jp.co.cyberagent.android.gpuimage.GPUImageView
    android:id="@+id/gpuimageview"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:gpuimage_show_loading="false"
    app:gpuimage_surface_type="texture_view" /> <!-- surface_view or texture_view -->

java code

@Override
public void onCreate(final Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity);

    Uri imageUri = ...;
    gpuImageView = findViewById(R.id.gpuimageview);
    gpuImageView.setImage(imageUri); // this loads image on the current thread, should be run in a thread
    gpuImageView.setFilter(new GPUImageSepiaFilter());

    // Later when image should be saved saved:
    gpuImageView.saveToPictures("GPUImage", "ImageWithFilter.jpg", null);
}

4.不带预览界面

和带预览界面是相对的,其专业的名称是离屏渲染,后面在分析代码的时候会再详情讲解

public void onCreate(final Bundle savedInstanceState) {
    public void onCreate(final Bundle savedInstanceState) {
    Uri imageUri = ...;
    gpuImage = new GPUImage(context);
    gpuImage.setFilter(new GPUImageSobelEdgeDetection());
    gpuImage.setImage(imageUri);
    gpuImage.saveToPictures("GPUImage", "ImageWithFilter.jpg", null);
}

OpenGL 原生的使用方式真是十分的啰嗦,过程繁多。而 Android 官方也没有出一个好用的 SDK 用以完善生态,减少开发者的工作。

三、源码分析

1.框架概览

框架图

image.png

上面是一个从输入——处理——输出的角度所绘制的一个框图,虽然 GPUImage 所涉及的知识是 OpenGL 等一些较有难度的图像知识,但其封装的框架相对来说是比较简单的。如上图所示,输入可以是一个 Bitmap 或者 一个 YUV 格式(一般是相机原始数据格式)的数据,然后经由 GPUImage 模块中的 GPUImageRender 进行渲染处理,在渲染之前先由 GPUImageFilter 进行处理,然后才真正渲染到 GLSurfaceView/GLTextureView 上,也就是屏幕上。或者也可以通过离屏渲染将结果渲染到 Buffer 中,最后保存到 Bitmap 中。

框架类图

GPUImage Main.jpg

GPUImage 可以看作是模块对外的接口,它封装了主要的类 GPUImageRenderer及其渲染的一些属性,而 GPUImageFilter 与 GLSurfaceView 均由外部传入,并与GPUImageRenderer 建立起联系。
GPUImageRenderer 其继承自 Render 类,主要负责调用 GPUImageFilter 进行图像的处理,再渲染到 GLSurfaceView 中。而这里所谓的处理,也就是通常所说的运用一些图像处理算法,只不过其不是通过 CPU 进行运算而是通过 GPU 进行运算。
GPUImageFilter 是所有 filter 的基类,其默认实现是不带任何滤镜效果。而其子类可以直接继承自 GPUImageFilter 从而实现单一的滤镜效果。或者也可以继承如 GPUImageFilterGroup 实现多个滤镜的效果。而关于如何组合,可以继承类图中如 GPUImage3x3TextureSamplingFilter 实现 3 张图片纹理采样的滤镜效果。当然也可以自己定义组织规则。

通过上面的框架图和框架类图,对 GPUImage 应该有一个整体的认知了。接下来我们按照带预览界面这个 demo 的流程先来分析一下更细节的实现原理。

2.带预览界面的渲染实现

初始化——构建 GPUImage

GPUImage初始化.jpg
/**
     * Instantiates a new GPUImage object.
     *
     * @param context the context
     */
    public GPUImage(final Context context) {
        if (!supportsOpenGLES2(context)) {
            throw new IllegalStateException("OpenGL ES 2.0 is not supported on this phone.");
        }

        this.context = context;
        filter = new GPUImageFilter();
        renderer = new GPUImageRenderer(filter);
    }

GPUImage 的构建非常简单,就是依次构建了 GPUImageFilter 和 GPUImageRender。GPUImageFilter 是所有 filter 的基类,它是不带任何滤镜效果的。同时它通过定义多个勾子方法来完成初始化,处理以及销毁的生命周期。如下图所示。

image.png

而它的构造方法也是很简单的,就是接收了顶点着色器脚本片元着色器脚本

public GPUImageFilter() {
        this(NO_FILTER_VERTEX_SHADER, NO_FILTER_FRAGMENT_SHADER);
    }

    public GPUImageFilter(final String vertexShader, final String fragmentShader) {
        runOnDraw = new LinkedList<>();
        this.vertexShader = vertexShader;
        this.fragmentShader = fragmentShader;
    }

关于着色器脚本,是一种 glsl 语言,风格类似于 c 语言,对此感兴趣的可以参考一下相关的wiki。而这两个着色器的作用分别是 OpenGL 流水线中用于计算顶点位置和给顶点上色的 2 个工序。对于完全没有接触过 OpenGL 的同学可能觉得这里看不明白,先不用着急,这里先有这个概念就可以了。

接着是创建 GPUImageRenderer,来看看其构造方法。

public GPUImageRenderer(final GPUImageFilter filter) {
        // 接收 filter
        this.filter = filter;
        // 创建 2 个任务队列
        runOnDraw = new LinkedList<>();
        runOnDrawEnd = new LinkedList<>();
        // 创建顶点 Buffer 并赋值
        glCubeBuffer = ByteBuffer.allocateDirect(CUBE.length * 4)
                .order(ByteOrder.nativeOrder())
                .asFloatBuffer();
        glCubeBuffer.put(CUBE).position(0);
        // 创建纹理 Buffer
        glTextureBuffer = ByteBuffer.allocateDirect(TEXTURE_NO_ROTATION.length * 4)
                .order(ByteOrder.nativeOrder())
                .asFloatBuffer();
        // 设置旋转方向
        setRotation(Rotation.NORMAL, false, false);
    }

GPUImageRenderer 的构造方法主要是构建了自己的运行时环境。其中最主要的是创建顶点 Buffer,创建纹理 Buffer 以及设置旋转方向。这里的 Buffer 分配涉及到的是 Java 的 NI/O,其分配置的内存空间是在 native 层。而这里 * 4 是因为 float 占 4 个字节。

先来看看 CUBE 的定义

public static final float CUBE[] = {
            -1.0f, -1.0f,//左下角坐标
            1.0f, -1.0f,//右下角坐标
            -1.0f, 1.0f,//左上角坐标
            1.0f, 1.0f,//右上角坐标
    };

这不是一堆没有意义的数字,这里其实是定义了一个 2 * 4 的顶点数组,2 代表是 2 维的,即 2 维坐标系中的某个点 (x,y);而 4 则代表是有 4 个顶点。再来看看这些数字的值,它们都在 -1 到 1 之间。这个就是与 OpenGL 中的众多坐标系相关了。OpenGL 的坐标系是 3 维的,它是以原点(0,0,0) 为中心,并有 3 个不同的方向 (x,y,z) 轴所组成的。这里所定义的顶点中,没有 z 坐标,即深度为 0。而之所以是在 -1 到 1 之间,是因为被归一化了。OpenGL 在流水线中,在最后做 NDC 运算后,会将所有的坐标都映射到 -1 到 1 之间。 如下是一个常见的 3 维坐标系。


image.png

而我们的这里定义的数字可以看成如下坐标系。


image.png

最终我们会拿这 4 个顶点来构造出 2 个三角形,从而形成一个面。在这个形成的面上,会将图片以纹理的形式贴在这个区域上。

再来看看纹理坐标 TEXTURE_NO_ROTATION 以及其他旋转角度的定义

public static final float TEXTURE_NO_ROTATION[] = {
            0.0f, 1.0f,
            1.0f, 1.0f,
            0.0f, 0.0f,
            1.0f, 0.0f,
    };

    public static final float TEXTURE_ROTATED_90[] = {
            1.0f, 1.0f,
            1.0f, 0.0f,
            0.0f, 1.0f,
            0.0f, 0.0f,
    };
    public static final float TEXTURE_ROTATED_180[] = {
            1.0f, 0.0f,
            0.0f, 0.0f,
            1.0f, 1.0f,
            0.0f, 1.0f,
    };
    public static final float TEXTURE_ROTATED_270[] = {
            0.0f, 0.0f,
            0.0f, 1.0f,
            1.0f, 0.0f,
            1.0f, 1.0f,
    };

纹理坐又是另一个坐标系,即纹理坐标系。我们熟悉的是 Android 的屏幕坐标系原点是在左上角的,而纹理坐标系的原点是以纹理的左下角为原点。并且是在 0 到 1 之间。而不管原来的图片宽高为多少,所有的坐标都会被映射成 0 到 1 之间的数值。对比一下如下纹理坐标系。当不进行任何旋转时,那么得到的坐标就是 TEXTURE_NO_ROTATION,而当作逆时针旋转 90 度时,得到的就是 TEXTURE_ROTATED_90。另外 2 个同理。


image.png

OpenGL 中的坐标系比较多,短短几句是讲不清楚的。这里只是根据坐标系的规则简单的描述了顶点和纹理坐标这些数值的由来。只做适当展开,不作详细深究。后面有机会会再专门进行 OpenGL 坐标系的讲解。

接着往下看 setRotation(),其还有另外 2 个参数代表是否要进行横向和众向的翻转,这与相机的角度和成像原理有关系,这里先不深入。看看其进一步调用的 adjustImageScaling()

private void adjustImageScaling() {
        float outputWidth = this.outputWidth;
        float outputHeight = this.outputHeight;
        // 竖屏情况下
        if (rotation == Rotation.ROTATION_270 || rotation == Rotation.ROTATION_90) {
            outputWidth = this.outputHeight;
            outputHeight = this.outputWidth;
        }
        // 这里相当于是把图片根据视口大小(简单理解为 GLSurfaceView的大小)进行比例缩放
        float ratio1 = outputWidth / imageWidth;
        float ratio2 = outputHeight / imageHeight;
        float ratioMax = Math.max(ratio1, ratio2);
        int imageWidthNew = Math.round(imageWidth * ratioMax);
        int imageHeightNew = Math.round(imageHeight * ratioMax);

        float ratioWidth = imageWidthNew / outputWidth;
        float ratioHeight = imageHeightNew / outputHeight;
        // 获取顶点数据
        float[] cube = CUBE;
        // 获取对应角度的纹理坐标,并根据翻转参数进行相应的翻转
        float[] textureCords = TextureRotationUtil.getRotation(rotation, flipHorizontal, flipVertical);
        // 根据 scaleType 对纹理坐标或者顶点坐标进行计算
        if (scaleType == GPUImage.ScaleType.CENTER_CROP) {
            float distHorizontal = (1 - 1 / ratioWidth) / 2;
            float distVertical = (1 - 1 / ratioHeight) / 2;
            textureCords = new float[]{
                    addDistance(textureCords[0], distHorizontal), addDistance(textureCords[1], distVertical),
                    addDistance(textureCords[2], distHorizontal), addDistance(textureCords[3], distVertical),
                    addDistance(textureCords[4], distHorizontal), addDistance(textureCords[5], distVertical),
                    addDistance(textureCords[6], distHorizontal), addDistance(textureCords[7], distVertical),
            };
        } else {
            cube = new float[]{
                    CUBE[0] / ratioHeight, CUBE[1] / ratioWidth,
                    CUBE[2] / ratioHeight, CUBE[3] / ratioWidth,
                    CUBE[4] / ratioHeight, CUBE[5] / ratioWidth,
                    CUBE[6] / ratioHeight, CUBE[7] / ratioWidth,
            };
        }
       // 最后把顶点坐标和纹理坐标送到相应的 buffer 中
        glCubeBuffer.clear();
        glCubeBuffer.put(cube).position(0);
        glTextureBuffer.clear();
        glTextureBuffer.put(textureCords).position(0);
    }

假设这里的 scaleType 是 CENTER_CROP,并假设图片的宽高为 80 * 200,而视口的宽高为 100 * 200,那么得到的效果如下图所示——注意超出橙色线框外的图像是不可见的,这里只是为了展示效果。


image.png

如果不是 CENTER_CROP,而是 CENTER_INSIDE,那么是改变顶点的位置。效果图如下。有兴趣的同学也可以自己仔细的推导一下。


image.png

这里最主的是通过 adjustImageScaling() 方法的计算,最终确定了顶点坐标以及纹理坐标,并送进了相应的 Buffer ,而这 2 个 Buffer 中的数字最终会被送到 OpenGL 的流水线中进行渲染。

建立与GLSurfaceView 的关联——GPUImage#setGLSurfaceView()

/**
     * Sets the GLSurfaceView which will display the preview.
     *
     * @param view the GLSurfaceView
     */
    public void setGLSurfaceView(final GLSurfaceView view) {
        surfaceType = SURFACE_TYPE_SURFACE_VIEW;
        glSurfaceView = view;
        glSurfaceView.setEGLContextClientVersion(2);
        glSurfaceView.setEGLConfigChooser(8, 8, 8, 8, 16, 0);
        glSurfaceView.getHolder().setFormat(PixelFormat.RGBA_8888);
        glSurfaceView.setRenderer(renderer);
        glSurfaceView.setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY);
        glSurfaceView.requestRender();
    }

该方法中设置了OpenGL 的版本,图片格式,刷新模式等,而最主要的是将 GPUImageRender 设置给了 GLSurfaceView,在 render 的 onDrawFrame() 勾子方法中将数据渲染到GLSurfaceView 的 Buffer 中。

GLSurfaceView 是 Android 自已定义的,除此之外,框架还定义了一个 GLTextureView,其继承自 TextureView。它的主要功能就是在模仿 GLSurfaceView,创建一个GLThread然后不断回调 render 的 onDrawFrame() ,从而达到不断刷新 View 的目的。关于SurfaceView 和 TextureView,这里稍微展开一下,有兴趣的可以了解一下,不感兴趣的也可以跳过:

SurfaceView 是一个有自己独立Surface的View,它的渲染可以放在单独线程而不是主线程中。作为一个 view 在App 进程中它也是在 view hierachy 中的,但在系统的 WindowManagerService 以及 SurfaceFlinger 中,它是有自己的 WindowState 和 Surface 的,简单理解就是有自己的画布——Buffer。因为它是不作变形和动画的。
TextureView 跟普通的View一样,在App进程中和系统的 WindowManagerService 以及 SurfaceFlinger 中都同属一个 view hierachy、widnowstate 和 surface。由 4.0 引入,早期还是靠主线程来渲染,在 5.0 之后加入了渲染线程,才由渲染线程来专门渲染。当然,和普通 View 一样,它是支持变形和动画的。另外,还有更重要的一点是,它必须在支持硬件加速的 window 中进行渲染,否则就会是一片空白。

最后的 glSurfaceView.requestRender() 会唤醒线程进行后续的渲染。

设置/更新图片源——GPUImage#setImage()/updatePreviewFrame()

设置图片源,可以是直接设置一个图片,图片可以是 bitmap,文件或者 URI。而其更常用的一个场景是相机的预览帧——YUV原始数据。当然,YUV数据也要转成通常所使用的 RGB 数据才能交给 Render 对其进行渲染。关于 YUV 请参考YUV 数据格式详解Video Rendering with 8-Bit YUV Formats。也可以看看下图直观的感受一下,“Y”表示明亮度(Luminance、Luma),“U”和“V”则是色度、浓度(Chrominance、Chroma)

image.png

不管是直接设置图片,还是原始YUV数据,都要将其绑定到 OpenGL 中的纹理 ID 中去。以 onPreviewFrame 来看一看。

public void onPreviewFrame(final byte[] data, final int width, final int height) {
        if (glRgbBuffer == null) {
            glRgbBuffer = IntBuffer.allocate(width * height);
        }
        if (runOnDraw.isEmpty()) {
            runOnDraw(new Runnable() {
                @Override
                public void run() {
                    // YUV 转 RGB
                    GPUImageNativeLibrary.YUVtoRBGA(data, width, height, glRgbBuffer.array());
                    // 加载纹理
                    glTextureId = OpenGlUtils.loadTexture(glRgbBuffer, width, height, glTextureId);

                    if (imageWidth != width) {
                        imageWidth = width;
                        imageHeight = height;
                        adjustImageScaling();
                    }
                }
            });
        }
    }

GPUImageNativeLibrary.YUVtoRBGA() 就不看了,来看一看 OpenGlUtils.loadTexture()。

public static int loadTexture(final IntBuffer data, final int width, final int height, final int usedTexId) {
        int textures[] = new int[1];
        if (usedTexId == NO_TEXTURE) {
            // 产生纹理 ID 数组,这里采样器只有一个,因此 1 个元素就够了
            GLES20.glGenTextures(1, textures, 0);
            // 绑定纹理采样器到纹理 ID
            GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textures[0]);
           // 设定采样的方式
            GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
                    GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
            GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
                    GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
            GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
                    GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
            GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
                    GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);
            // 将图片 buffer 送进 OpenGL 的纹理采样器中
            GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, width, height,
                    0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, data);
        } else {
            GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, usedTexId);
            GLES20.glTexSubImage2D(GLES20.GL_TEXTURE_2D, 0, 0, 0, width,
                    height, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, data);
            textures[0] = usedTexId;
        }
        return textures[0];
    }

这个方法的其他细节请参考注释即可。通过这个方法的主要目的就是将图片送进 OpenGL 的 sample2D 采样器中,此 Sample2D 采样器是在 片元 Shader 脚本中定义的。如下定义中的 inputImageTexture。

public static final String NO_FILTER_FRAGMENT_SHADER = "" +
            "varying highp vec2 textureCoordinate;\n" +
            " \n" +
            "uniform sampler2D inputImageTexture;\n" +
            " \n" +
            "void main()\n" +
            "{\n" +
            "     gl_FragColor = texture2D(inputImageTexture, textureCoordinate);\n" +
            "}";

设置 Filter——GPUImage#setImageFilter()

/**
     * Sets the filter which should be applied to the image which was (or will
     * be) set by setImage(...).
     *
     * @param filter the new filter
     */
    public void setFilter(final GPUImageFilter filter) {
        this.filter = filter;
        renderer.setFilter(this.filter);
        requestRender();
    }

调用了 render 的 setFilter,并再次发起渲染请求。来进一步看看。

public void setFilter(final GPUImageFilter filter) {
        runOnDraw(new Runnable() {

            @Override
            public void run() {
                final GPUImageFilter oldFilter = GPUImageRenderer.this.filter;
                GPUImageRenderer.this.filter = filter;
               // 如果存在有旧的 filter,则先销毁
                if (oldFilter != null) {
                    oldFilter.destroy();
                }
                // 然后调用 fiter.ifNeedInit() 进行初始化
                GPUImageRenderer.this.filter.ifNeedInit();
                // 设置 OpenGL 上下文所使用的程序 ID
                GLES20.glUseProgram(GPUImageRenderer.this.filter.getProgram());
                // 更新视口大小
                GPUImageRenderer.this.filter.onOutputSizeChanged(outputWidth, outputHeight);
            }
        });
    }

这里的主要过程就对应了前面 GPUImageFilter 生命周期的流程图。其首先判断是否有旧的 filter,如果有则先销毁。销毁很简单,主要就是通过 GLES20.glDeleteProgram(glProgId) 销毁 OpenGL 当前运行的程序 ID,然后再通过勾子方法 onDestroy() 通知 GPUImageFilter 的子类释放其他所用到的资源。这里重点需要了解一下的是其初始化的过程。

ififNeedInit() 主要就是调用了 onInit()

public void onInit() {
        glProgId = OpenGlUtils.loadProgram(vertexShader, fragmentShader);
        glAttribPosition = GLES20.glGetAttribLocation(glProgId, "position");
        glUniformTexture = GLES20.glGetUniformLocation(glProgId, "inputImageTexture");
        glAttribTextureCoordinate = GLES20.glGetAttribLocation(glProgId, "inputTextureCoordinate");
        isInitialized = true;
    }

创建程序ID,获取 顶点位置属性 "position",纹理坐标属性"inputTextureCoordinate",统一变量"inputImageTexture"。这里主要是 loadProgram() 需要说一下,其主要完成的功能便是加载顶点以及片元着色器,然后创建程序,附加着色器,最后链接程序。这些过程都是 OpenGL 编程过程中所必须经历的步骤,这里只稍做了解即可。为了文章的完整性,这里也将相关的代码贴出来。

public static int loadProgram(final String strVSource, final String strFSource) {
        int iVShader;
        int iFShader;
        int iProgId;
        int[] link = new int[1];
        iVShader = loadShader(strVSource, GLES20.GL_VERTEX_SHADER);
        if (iVShader == 0) {
            Log.d("Load Program", "Vertex Shader Failed");
            return 0;
        }
        iFShader = loadShader(strFSource, GLES20.GL_FRAGMENT_SHADER);
        if (iFShader == 0) {
            Log.d("Load Program", "Fragment Shader Failed");
            return 0;
        }

        iProgId = GLES20.glCreateProgram();

        GLES20.glAttachShader(iProgId, iVShader);
        GLES20.glAttachShader(iProgId, iFShader);

        GLES20.glLinkProgram(iProgId);

        GLES20.glGetProgramiv(iProgId, GLES20.GL_LINK_STATUS, link, 0);
        if (link[0] <= 0) {
            Log.d("Load Program", "Linking Failed");
            return 0;
        }
        GLES20.glDeleteShader(iVShader);
        GLES20.glDeleteShader(iFShader);
        return iProgId;
    }
public static int loadShader(final String strSource, final int iType) {
        int[] compiled = new int[1];
        int iShader = GLES20.glCreateShader(iType);
        GLES20.glShaderSource(iShader, strSource);
        GLES20.glCompileShader(iShader);
        GLES20.glGetShaderiv(iShader, GLES20.GL_COMPILE_STATUS, compiled, 0);
        if (compiled[0] == 0) {
            Log.d("Load Shader Failed", "Compilation\n" + GLES20.glGetShaderInfoLog(iShader));
            return 0;
        }
        return iShader;
    }

至此,可以说用来渲染图片的环境是已经建立好了。如确定顶点坐标,缩放方式,建立OpenGL的渲染环境等等。下面就看如何绘制出来了。

渲染——渲染 Filter

前面在介绍 Render 的时候有讲过,GLSurfaceView 就是通过 GLThread 不断回调 render 的勾子方法 onDrawFrame() 来达到刷新 view 的目的。那么我们来看看 GPUImageRenderer 的 onDrawFrame()。

@Override
    public void onDrawFrame(final GL10 gl) {
        GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT);
        runAll(runOnDraw);
        filter.onDraw(glTextureId, glCubeBuffer, glTextureBuffer);
        runAll(runOnDrawEnd);
        if (surfaceTexture != null) {
            surfaceTexture.updateTexImage();
        }
    }

其实应该能想得到,其最主要的就是通过调用 filter 的 onDraw() 进行渲染。

public void onDraw(final int textureId, final FloatBuffer cubeBuffer,
                       final FloatBuffer textureBuffer) {
        // 激程序 ID
        GLES20.glUseProgram(glProgId);
        runPendingOnDrawTasks();
        if (!isInitialized) {
            return;
        }
       // 将顶点 buffer 的数据送给属性 "position",并使能属性
        cubeBuffer.position(0);
       // 下面的 2 表示每个点的 size 大小,即这里的一个坐标只需要取 2 个表示 (x,y) 即可。如果为 3 则表示 (x,y,z)
        GLES20.glVertexAttribPointer(glAttribPosition, 2, GLES20.GL_FLOAT, false, 0, cubeBuffer);
        GLES20.glEnableVertexAttribArray(glAttribPosition);
        // 将纹理坐标 buffer 的数据送给属性 "inputTextureCoordinate",并使能属性
        textureBuffer.position(0);
        GLES20.glVertexAttribPointer(glAttribTextureCoordinate, 2, GLES20.GL_FLOAT, false, 0,
                textureBuffer);
        GLES20.glEnableVertexAttribArray(glAttribTextureCoordinate);
        if (textureId != OpenGlUtils.NO_TEXTURE) {
            // 激活,绑定纹理,并指定采样器 "inputImageTexture" 为 0 号纹理
            GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
            GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
            GLES20.glUniform1i(glUniformTexture, 0);
        }
        onDrawArraysPre();
        // 绘制 3 角形
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
        GLES20.glDisableVertexAttribArray(glAttribPosition);
        GLES20.glDisableVertexAttribArray(glAttribTextureCoordinate);
        GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0);
    }

渲染的过程就是 OpenGL 方法的一些调用,其中的意思也都在代码里增加了注释说明。其子类 Filter 也都采用这个 onDraw() 进行绘制。而决定每个 filter 渲染出什么样的滤镜效果就都在其定义的顶点着色器和片元着色器里了。

至此,将图片经 GPUImageFilter 渲染到 GLSurfaceView 上的过程已经分析完了。如前面所说,有了 GPUImage 这个框架,就不需要我们去处理 OpenGL 里面的各种繁琐的细节了。一般的,我们只需要写好我们自己的着色器,剩下的就都可以交给 GPUImage 来完成了。

3.离屏渲染

所谓离屏渲染,就是将Render渲染出来的图片不送进 GLSurfaceView,而保存在特定的 Buffer 中。下面看看它的时序图。


离屏渲染.jpg

初始化离屏渲染的环境

其中的 1 - 4 步比较简单,就不展开了。从 getBitmapWithFilterApplied () 开始。

/**
     * Gets the given bitmap with current filter applied as a Bitmap.
     *
     * @param bitmap  the bitmap on which the current filter should be applied
     * @param recycle recycle the bitmap or not.
     * @return the bitmap with filter applied
     */
    public Bitmap getBitmapWithFilterApplied(final Bitmap bitmap, boolean recycle) {
        ......
        GPUImageRenderer renderer = new GPUImageRenderer(filter);
        renderer.setRotation(Rotation.NORMAL,
                this.renderer.isFlippedHorizontally(), this.renderer.isFlippedVertically());
        renderer.setScaleType(scaleType);
        PixelBuffer buffer = new PixelBuffer(bitmap.getWidth(), bitmap.getHeight());
        buffer.setRenderer(renderer);
        renderer.setImageBitmap(bitmap, recycle);
        Bitmap result = buffer.getBitmap();
        filter.destroy();
        renderer.deleteImage();
        buffer.destroy();

        this.renderer.setFilter(filter);
        if (currentBitmap != null) {
            this.renderer.setImageBitmap(currentBitmap, false);
        }
        requestRender();

        return result;
    }

省略的部分与 GLSurfaceView 相关,主要主是销毁的相关工作。构造 GPUImageRenderer 前面也分析过了。这里主要只分析 PixelBuffer 相关的调用。首先看看其构造函数。

public PixelBuffer(final int width, final int height) {
        this.width = width;
        this.height = height;

        int[] version = new int[2];
        int[] attribList = new int[]{
                EGL_WIDTH, this.width,
                EGL_HEIGHT, this.height,
                EGL_NONE
        };

        // No error checking performed, minimum required code to elucidate logic
        // 创建 egl  
        egl10 = (EGL10) EGLContext.getEGL();
        // 获取 default_display  
        eglDisplay = egl10.eglGetDisplay(EGL_DEFAULT_DISPLAY);
        egl10.eglInitialize(eglDisplay, version);
        eglConfig = chooseConfig(); // Choosing a config is a little more
  
        int EGL_CONTEXT_CLIENT_VERSION = 0x3098;
        int[] attrib_list = {
                EGL_CONTEXT_CLIENT_VERSION, 2,
                EGL10.EGL_NONE
        };
        // 创建上下文
        eglContext = egl10.eglCreateContext(eglDisplay, eglConfig, EGL_NO_CONTEXT, attrib_list);
        // 在显存中开辟一个 Buffer,渲染后的图片将存放在这里
        eglSurface = egl10.eglCreatePbufferSurface(eglDisplay, eglConfig, attribList);
        egl10.eglMakeCurrent(eglDisplay, eglSurface, eglSurface, eglContext);

        gl10 = (GL10) eglContext.getGL();

        // Record thread owner of OpenGL context
        mThreadOwner = Thread.currentThread().getName();
    }

关键过程在注释中都有添加。这里主要关注的是 eglCreatePbufferSurface() 的调用,其主要作用就是在显存中开辟一个 buffer,并不关联任何屏幕上的 window。那与之对应的 GLSurfaceView 是否有在屏幕的 window 上开辟一个 buffer 呢。

public EGLSurface createWindowSurface(EGL10 egl, EGLDisplay display,
                EGLConfig config, Object nativeWindow) {
            EGLSurface result = null;
            try {
                result = egl.eglCreateWindowSurface(display, config, nativeWindow, null);
            } catch (IllegalArgumentException e) {
                ......
            }
            return result;
        }

如 GLSurfaceView 中创建 EGLSurface 的代码所示,果然是有的,只不过它调用的是另一个方法 eglCreateWindowSurface()。这里所传的参数里需要注意的是nativeWindow,它其实就是 SurfaceHolder。

到这里也就创建好了离屏渲染所需要的环境,接着与之前一样,给 GPUImageRenderer 设置图片以及 Filter 并作好相关渲染准备。

获取渲染结果

先调用 getBitmap() ,该方法中会进一步调用 render 的 onDrawFrame,从而使得图片按照 filter 所希望的效果将图片渲染到 PixelBuffer 中所创建的 EGLSurface 中。然后调用 convertToBitmap() 方法将EGLSurface 中的 buffer 中的内容转换成 bitmap。

convertToBitmap() 只是一个简单的调用,其进一步调用了 native 函数 GPUImageNativeLibrary.adjustBitmap(bitmap) 来真正执行转换的操作。

JNIEXPORT void JNICALL
Java_jp_co_cyberagent_android_gpuimage_GPUImageNativeLibrary_adjustBitmap(JNIEnv *jenv, jclass thiz,
                                                                       jobject src) {
    unsigned char *srcByteBuffer;
    int result = 0;
    int i, j;
    // 声明一个 AndroidBitmapInfo 结构
    AndroidBitmapInfo srcInfo;
    // 从图片中获取 info
    result = AndroidBitmap_getInfo(jenv, src, &srcInfo);
    if (result != ANDROID_BITMAP_RESULT_SUCCESS) {
        return;
    }
    // 将图片 src 的数据指针赋值给 srcByteBuffer
    result = AndroidBitmap_lockPixels(jenv, src, (void **) &srcByteBuffer);
    if (result != ANDROID_BITMAP_RESULT_SUCCESS) {
        return;
    }

    int width = srcInfo.width;
    int height = srcInfo.height;
    // 从当前 EGL 运行环境中读取图片数据并保存在 srcByteBuffer 中,也就保存到了位图里面了
    glReadPixels(0, 0, srcInfo.width, srcInfo.height, GL_RGBA, GL_UNSIGNED_BYTE, srcByteBuffer);

    int *pIntBuffer = (int *) srcByteBuffer;
   // OpenGL和Android的Bitmap色彩空间不一致,这里需要做转换。以中间为基线进行对调。
    for (i = 0; i < height / 2; i++) {
        for (j = 0; j < width; j++) {
            int temp = pIntBuffer[(height - i - 1) * width + j];
            pIntBuffer[(height - i - 1) * width + j] = pIntBuffer[i * width + j];
            pIntBuffer[i * width + j] = temp;
        }
    }
    AndroidBitmap_unlockPixels(jenv, src);
}

这段代码可能有些是似曾相识的。当我们在完成截屏功能时,如果碰到有 video 的时候,截出来是黑的。有很多大神提供实现工具,而其内部的原理就是这个,即读取当前上下文的 buffer 中的图片数据,然后保存到 bitmap 或者 创建 bitmap。由于在 OpenGL 的 buffer 中其顺序是 左上 到 右下,而图片纹理的顺序是 左下 到 右上。因此需要以中间为基准将数据进行对调。

以上,便是离屏渲染的大致分析。

四、后记

同样感谢你能读到此文章,也希望你能有所收获。当然,对于 GPUImage 的分析与阅读需要有一定的 OpenGL 的基础,不然会觉得里面的概念繁多而且也比较抽象。另外,文章主要只是分析了 GPUImage 使用 filter 进行界面渲染或者离屏渲染过程的一个解读。由于我在图形图像领域也只是一个稍微入了门的小菜鸟,对于图像处理算法更是知之甚少,所以对于 Filter 的具体算法实现没有进行分析。对于文中的分析,如存在错误或者有不清楚的地方,也欢迎留言讨论,将不胜感激。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容