惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

雷峰网
雷峰网
N
Netflix TechBlog - Medium
博客园_首页
J
Java Code Geeks
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Y
Y Combinator Blog
腾讯CDC
V
V2EX
Microsoft Security Blog
Microsoft Security Blog
大猫的无限游戏
大猫的无限游戏
Cyberwarzone
Cyberwarzone
N
News and Events Feed by Topic
L
LINUX DO - 最新话题
Schneier on Security
Schneier on Security
Microsoft Azure Blog
Microsoft Azure Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Hacker News: Ask HN
Hacker News: Ask HN
Martin Fowler
Martin Fowler
Google DeepMind News
Google DeepMind News
G
Google Developers Blog
U
Unit 42
WordPress大学
WordPress大学
N
News and Events Feed by Topic
S
Schneier on Security
T
The Blog of Author Tim Ferriss
B
Blog
博客园 - 叶小钗
Forbes - Security
Forbes - Security
F
Fortinet All Blogs
Project Zero
Project Zero
K
Kaspersky official blog
Apple Machine Learning Research
Apple Machine Learning Research
L
LINUX DO - 热门话题
The GitHub Blog
The GitHub Blog
H
Hacker News: Front Page
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
PCI Perspectives
PCI Perspectives
The Register - Security
The Register - Security
www.infosecurity-magazine.com
www.infosecurity-magazine.com
W
WeLiveSecurity
C
Cyber Attacks, Cyber Crime and Cyber Security
罗磊的独立博客
S
Security @ Cisco Blogs
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
T
Tailwind CSS Blog
P
Proofpoint News Feed
S
SegmentFault 最新的问题
D
Docker
量子位
M
MIT News - Artificial intelligence

音视频开发进阶

音视频教程-第三节 音视频教程-第二节 真的,AI 可能就是新时代的信息差 充值 Cursor 之后,工作有了哪些变化?🤔 个人'蒸馏'大模型能做哪些有意思的事情 DeepSeek 大模型在 Mac 上的部署和运行 音视频教程-第一节 【WebRTC 专栏】-- Android 开发集成 WebRTC 库的几种方式 【WebRTC 专栏】-- 在 Mac M1 等系列芯片编译和开发 WebRTC-Android 库 Meta Llama3 大模型在 Mac 上的部署和运行 iOS VideoToolBox 解码 HEVC Open-GOP 视频的问题排查 Flutter 状态管理之 InheritedWidget 使用和分析 用 ChatGPT 回答技术问题怎么样 ? 音视频开发系统入门大致路线 UE 4.27 添加自定义 ShadingModel 用 UE4 虚幻引擎做个捏脸小功能~~ UE4 材质练习 之 凹凸贴图偏移的使用 UE4 材质练习系列基础 OpenGL上下文创建以及共享机制 007 | 播放器系列专栏-解析 MP4 文件读取信息 006 | 播放器系列专栏-在 Mac 上查看 MP4 格式信息 干货 | 快速抽取缩略图是怎么练成的? 关于直播、WebRTC、FFmpeg 的那些事 005 | 播放器系列专栏-在 Windows 上查看 MP4 格式信息 将音视频中的花屏、绿屏、黑屏问题一网打尽 关于音视频里面的解码帧率和渲染帧率 004 | 播放器系列专栏-认识MP4视频(下) 003 | 播放器系列专栏-认识MP4视频(上) 入门或者转行音视频,应该要怎么做? H264视频文件如何缩放分辨率 002 | 播放器系列专栏-FFmpeg依赖库的配置 001 | 播放器系列专栏-关于播放器项目的一个小实践 Seek策略以及在有B帧情况下的处理 目前流媒体开发工程师工作内容主要是什么? 一个音视频领域专业问答的小圈子 干货收藏 || Vulkan Game Engine 视频教程 音视频春节假期内卷指南(实操) Vulkan 在 FFmpeg 中的支持 Windows 下 FFmpeg 和 LibX264 的编译和配置 Metal 开发 | 使用 C++ 进行接口调用 音视频开发工作经验分享 || 视频版 FFmpeg 调用 MediaCodec 硬解码到 Surface 上 代码吸猫 | 用 OpenGL 图像渲染的养猫计划 百倍变速--解码到底能不能丢 非参考帧 ?FFmpeg 有话说!!! 老生常谈-FFmpeg 的编译问题轻松搞定 FFmpeg 调用 Android MediaCodec 进行硬解码(附源码) 【WebRTC 专栏】--创建相机预览 Unity Shader 光照基础之 Half Lambert 光照模型 Unity Shader 光照基础之Lambert光照模型 Unity Shader 光照基础内容 Unity Shader 显示一张图片纹理 UnityShader 的基本概念 Unity 物体的基本操作 C++ 模板系列小结07-尾置返回类型 C++ 模板系列小结06-可变参数模板特性 C++ 中的多线程的使用和线程池建设 C++ 模板系列小结05-模板类型作为模板参数 C++ 模板系列小结04-类模板中的成员模板 C++ 模板系列小结03-在模板中指定变量类型 C++ 模板系列小结02-非类型模板参数 C++ 模板系列小结01-函数模板和类模板 从零打造渲染引擎系列01-什么是渲染引擎 iOS开发 - 在 Swift 中去调用 C/C++ 代码 2021 技术新番 - 从零打造渲染引擎系列 iOS 音视频开发的一些基础准备工作 音视频交流群又来啦~~~ 【WebRTC 专栏】WebRTC & Android 开发学习环境搭建~ 【喜大普奔】域名终于备案通过啦 Shader 优化 | OpenGL 绘制网格效果 【音视频连载-011】第二季 FFmpeg 一层一层获取文件信息 KodeLife | Shader 实时编辑预览的强大工具使用实践 推荐几个堪称教科书级别的 Android 音视频入门项目 【音视频连载-010】第二季 FFmpeg 日志打印 【音视频连载-008】基础学习篇-SDL 播放 PCM 音频文件(下) 【音视频连载-007】基础学习篇-SDL 播放 PCM 音频文件(上) 【音视频连载-006】基础学习篇-SDL 播放 YUV 视频文件 【音视频连载-005】基础学习篇-SDL 加载 YUV 文件并显示 【音视频连载-004】基础学习篇-SDL 加载图片并显示 【音视频连载-003】基础学习篇-SDL 消息循环和事件响应 【音视频连载-002】基础学习篇-SDL 创建窗口并显示颜色 【音视频连载-001】基础学习篇- SDL 介绍以及工程配置 LearnOpenGL 源码在 MAC 上的编译与调试 2019 年终总结与回顾 Android NDK 开发的免费技术视频来啦~~ OpenGL 实现视频编辑中的转场效果 OpenGL 实践之贝塞尔曲线绘制 图像库 libjpeg-turbo 编译与实践 图像库 libpng 编译与实践 rust 开发编译 Android 动态库实践 Android NDK 开发 —— 从 Assets 文件夹加载图片并上传纹理 简单易用的图像解码库介绍 —— stb_image 博客图床迁移记 进击的 Vulkan 移动开发之 SwapChain 进击的 Vulkan 移动开发之 Command Buffer 进击的 Vulkan 移动开发之 Instance & Device & Queue 进击的 Vulkan 移动开发(一)之今生前世 Java 显式锁 Lock 与条件队列 C++ 标准容器库小结 一文读懂 YUV 的采样与格式 《OpenGL ES 3.x 游戏开发》碰撞检测之 AABB 包围盒
OpenGL 之 GPUImage 源码分析
音视频开发进阶 · 2018-09-02 · via 音视频开发进阶

一个专注音视频领域的小圈子

避免图片丢失,建议阅读微信原文:

https://mp.weixin.qq.com/s/2EeI91pu6y6Z7pnLPToXYA GPUImage 是 iOS 上一个基于 OpenGL 进行图像处理的开源框架,后来有人借鉴它的想法实现了一个 Android 版本的 GPUImage ,本文也主要对 Android 版本的 GPUImage 进行分析。

概要

在 GPUImage 中既有对图像进行处理的,也有对相机内容进行处理的,这里主要以相机处理为例进行分析。

大致会分为三个部分:

  • 相机数据的采集
  • OpenGL 对图像的处理与显示
  • 相机的拍摄

相机数据采集

相机数据采集实际上就是把相机的图像数据转换成 OpenGL 中的纹理。

在相机的业务开发中,会给相机设置 PreviewCallback 回调方法,只要相机处于预览阶段,这个回调就会被重复调用,返回当前预览帧的内容。

	camera.setPreviewCallback(GPUImageRenderer.this);
    camera.startPreview();

默认情况下,相机返回的数据是 NV21 格式,也就是 YCbCr_420_SP 格式,而 OpenGL 使用的纹理是 RGB 格式,所以在每一次的回调方法中需要将 YUV 格式的数据转换成 RGB 格式数据。

GPUImageNativeLibrary.YUVtoRBGA(data, previewSize.width, previewSize.height,
						     mGLRgbBuffer.array());

有了图像的 RGB 数据,就可以使用 glGenTextures 生成纹理,并用 glTexImage2D 方法将图像数据作为纹理。

另外,如果纹理已经生成了,当再有图像数据过来时,只需要更新数据就好了,无需重复创建纹理。

	// 根据图像数据加载纹理
    public static int loadTexture(final IntBuffer data, final Size size, final int usedTexId) {
        int textures[] = new int[1];
        if (usedTexId == NO_TEXTURE) {
            GLES20.glGenTextures(1, textures, 0);
            GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textures[0]);
	        // 省略部分代码
            GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, size.width, size.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, size.width,
                    size.height, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, data);
            textures[0] = usedTexId;
        }
        return textures[0];
    }

通过在 PreviewCallback 回调方法中的操作,就完成了将图像数据转换为 OpenGL 的纹理。

接下来就是如何将纹理数据进行处理,并且显示到屏幕上。

在相机数据采集中,还有一些小的细节问题,比如相机前置与后置摄像头的左右镜像翻转问题。

对于前置摄像头,再把传感器内容作为纹理显示时,前置摄像头要做一个左右的翻转处理,因为我们看到的是一个镜像内容,符合正常的自拍流程。

在 GPUImage 的 TextureRotationUtil 类中有定义了纹理坐标,这些纹理坐标系的原点不是位于左下角进行定义的,而是位于左上角。

如果以左下角为纹理坐标系的坐标原点,那么除了要将纹理坐标向右顺时针旋转 90° 之外,还需要进行上下翻转才行,至于为什么要向右顺时针旋转 90° ,参考这篇文章:

Android 相机开发中的尺寸和方向问题

当我们把纹理坐标以左上角为原点,并相对于顶点坐标顺时针旋转 90 ° 之后,才能够正常的显示图像:

	// 顶点坐标
    static final float CUBE[] = {
            -1.0f, -1.0f,
            1.0f, -1.0f,
            -1.0f, 1.0f,
            1.0f, 1.0f,
    };
    // 以左上角为原点,并相对于顶点坐标顺时针旋转 90° 后的纹理坐标
    public static final float TEXTURE_ROTATED_90[] = {
            1.0f, 1.0f,
            1.0f, 0.0f,
            0.0f, 1.0f,
            0.0f, 0.0f,
    };

图像处理与显示

在有了纹理之后,需要明确的是,这个纹理就是相机采集到的图像内容,我们要将纹理绘制到屏幕上,实际上是绘制一个矩形,然后纹理是贴在这个矩形上的。

所以,这里可以回顾一下 OpenGL 是如何绘制矩形的,并且将纹理贴到矩形上。

OpenGL 学习系列—纹理

在 GPUImage 中,GPUImageFilter 类就完成了上述的操作,它是 OpenGL 中所有滤镜的基类。

解析 GPUImageFilter 的代码实现:

在 GPUImageFilter 的构造方法中会确定好需要使用的顶点着色器和片段着色器脚本内容。

init 方法中会调用 onInit 方法和 onInitialized 方法。

  • onInit 方法会创建 OpenGL 中的 Program,并且会绑定到着色器脚本中声明的 attributeuniform 变量字段。
  • onInitialized 方法会给一些 uniform 字段变量赋值,在 GPUImageFilter 类中还对不同类型的变量赋值进行了对应的方法,比如对 float 变量:
    protected void setFloat(final int location, final float floatValue) {
        runOnDraw(new Runnable() {
            @Override
            public void run() {
                GLES20.glUniform1f(location, floatValue);
            }
        });
    }

在 onDraw 方法中就是执行具体的绘制了,在绘制的时候会执行 runPendingOnDrawTasks 方法,这是因为我们在 init 方法去中给着色器语言中的变量赋值,并没有立即生效,而是添加到了一个链表中,所以需要把链表中的任务执行完了才接着执行绘制。

    public void onDraw(final int textureId, final FloatBuffer cubeBuffer,
                       final FloatBuffer textureBuffer) {
        GLES20.glUseProgram(mGLProgId);
        // 执行赋值的任务
        runPendingOnDrawTasks();
		// 顶点和纹理坐标数据
        GLES20.glVertexAttribPointer(mGLAttribPosition, 2, GLES20.GL_FLOAT, false, 0, cubeBuffer);
        GLES20.glEnableVertexAttribArray(mGLAttribPosition);
        GLES20.glVertexAttribPointer(mGLAttribTextureCoordinate, 2, GLES20.GL_FLOAT, false, 0, textureBuffer);
        GLES20.glEnableVertexAttribArray(mGLAttribTextureCoordinate);
		// 在绘制前的最后一波操作
        onDrawArraysPre();
        // 最终绘制
        GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
    }

在绘制时,还需要给顶点坐标赋值,给纹理坐标赋值,GPUImageFilter 并没有去管理顶点坐标和纹理坐标,而是通过传递参数的形式,这样就不用去处理在前置摄像头与后置前摄像头、手机竖立放置与横屏放置时的关系了。

在执行具体的 glDrawArrays 方法之前,还提供了一个 onDrawArraysPre 方法,在这个方法里面还可以执行绘制前的最后一波操作,在某些滤镜的实现中有用到了。

最后才是 glDrawArrays 方法去完成绘制。

当我们不需要 GPUImageFilter 进行绘制时,需要将它销毁掉,在 destroy 方法去进行销毁,并且提供 onDestory 方法去为某些滤镜提供自定义的销毁。

    public final void destroy() {
        mIsInitialized = false;
        GLES20.glDeleteProgram(mGLProgId);
        onDestroy();
    }

    public void onDestroy() {
    }

在 GPUImageFilter 方法中定义了片段着色器脚本,这个脚本是将图像内容原样贴到了矩形上,并没有做特殊的图像处理操作。

而其他滤镜中,更改了着色器脚本,也就会对图像进行其他的处理,在整个 GPUImage 项目中,最精华的也就是那些着色器脚本内容了,如何通过着色器去做图像处理又是一门高深的学问了~~~

解析 GPUImageFilterGroup 的代码实现

当想要对图像进行多次处理时,就得考虑使用 GPUImageFilterGroup 了。

GPUImageFilterGroup 继承自 GPUImageFilter, 顾名思义就是一系列 GPUImageFilter 滤镜的组合,可以把它类比为 ViewGroup ,ViewGroup 即可以包含 View ,也可以包含 ViewGroup ,同样 GPUImageFilterGroup 即可以包含 GPUImageFilter,也可以包含 GPUImageFilterGroup。

在用 GPUImageFilterGroup 进行绘制时,需要把所有的滤镜内容都进行一遍绘制,而对于 GPUImageFilterGroup 包含 GPUImageFilterGroup 的情况,就需要把子 GPUImageFilterGroup 内的滤镜内容拆分出来,最终是用 mMergedFilters 变量表示所有非 GPUImageFilterGroup 类型的 GPUImageFilter 。

	// 拿到所有非 GPUImageFilterGroup 的 GPUImageFilter
    public void updateMergedFilters() {
        List<GPUImageFilter> filters;
        for (GPUImageFilter filter : mFilters) {
	        // 如果滤镜是 GPUImageFilterGroup 类型,就把它拆分了
            if (filter instanceof GPUImageFilterGroup) {
	            // 递归调用 updateMergedFilters 方法去拆分
                ((GPUImageFilterGroup) filter).updateMergedFilters();
                // 拿到所有非 GPUImageFilterGroup 的 GPUImageFilter
                filters = ((GPUImageFilterGroup) filter).getMergedFilters();
                if (filters == null || filters.isEmpty())
                    continue;
                // 把 GPUImageFilter 添加到 mMergedFilters 中
                mMergedFilters.addAll(filters);
                continue;
            }
            // 如果是非 GPUImageFilterGroup 直接添加了
            mMergedFilters.add(filter);
        }
    }

在 GPUImageFilterGroup 执行具体的绘制之前,还创建了和滤镜数量一样多的 FrameBuffer 帧缓冲和 Texture 纹理。

		// 遍历时,选择 mMergedFilters 的长度,因为 mMergedFilters 里面才是保存的所有的 滤镜的长度。
        if (mMergedFilters != null && mMergedFilters.size() > 0) {
            size = mMergedFilters.size();
            // FrameBuffer 帧缓冲数量
            mFrameBuffers = new int[size - 1];
            // 纹理数量
            mFrameBufferTextures = new int[size - 1];

            for (int i = 0; i < size - 1; i++) {
				// 生成 FrameBuffer 帧缓冲
                GLES20.glGenFramebuffers(1, mFrameBuffers, i);
                // 生成纹理
                GLES20.glGenTextures(1, mFrameBufferTextures, i);
                GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mFrameBufferTextures[i]);
                GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, width, height, 0,
                        GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null);
                // 省略部分代码
                GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, mFrameBuffers[i]);
                // 纹理绑定到帧缓冲上
                GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0,
                        GLES20.GL_TEXTURE_2D, mFrameBufferTextures[i], 0);
                // 省略部分代码
            }
        }

如果对 FrameBuffer 的使用不熟悉的话,请参考这篇文章:

OpenGL 之 帧缓冲 使用实践

        if (mMergedFilters != null) {
            int size = mMergedFilters.size();
            // 相机原始图像转换的纹理 ID
            int previousTexture = textureId;
            for (int i = 0; i < size; i++) {
                GPUImageFilter filter = mMergedFilters.get(i);
                boolean isNotLast = i < size - 1;
                // 如果不是最后一个滤镜,绘制到 FrameBuffer 上,如果是最后一个,就绘制到了屏幕上
                if (isNotLast) {
                    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, mFrameBuffers[i]);
                    GLES20.glClearColor(0, 0, 0, 0);
                }
				// 滤镜绘制代码
                if (i == 0) {
	                // 第一个滤镜绘制使用相机的原始图像纹理 ID 和参数传递过来的顶点以及纹理坐标
                    filter.onDraw(previousTexture, cubeBuffer, textureBuffer);
                } else if (i == size - 1) {
	                // 
                    filter.onDraw(previousTexture, mGLCubeBuffer, (size % 2 == 0) ? mGLTextureFlipBuffer : mGLTextureBuffer);
                } else {
	                // 中间的滤镜绘制在之前纹理基础上继续绘制,使用 mGLTextureBuffer 纹理坐标
                    filter.onDraw(previousTexture, mGLCubeBuffer, mGLTextureBuffer);
                }

                if (isNotLast) {
	                // 绑定到屏幕上
                    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
                    previousTexture = mFrameBufferTextures[i];
                }
            }
        }

在执行具体的绘制时,只要不是最后一个滤镜,那么就会先绑定到 FrameBuffer 上,然后在 FrameBuffer 上进行绘制,这时绘制是绘制到了 FrameBuffer 绑定的纹理上,绘制结束后再接着解绑,绑定到屏幕上。

如果是最后一个滤镜,那么就不用绑定到 FrameBuffer 上了,直接绘制到屏幕上即可。

在这里有个细节,就是如下的代码:

    filter.onDraw(previousTexture, mGLCubeBuffer, (size % 2 == 0) ? mGLTextureFlipBuffer : mGLTextureBuffer);

如果是最后一个滤镜,并且滤镜个数为偶数,则使用 mGLTextureFlipBuffer 的纹理坐标,否则使用 mGLTextureBuffer 的纹理坐标。

// 对应的纹理坐标为 TEXTURE_NO_ROTATION
 mGLTextureBuffer.put(TEXTURE_NO_ROTATION).position(0);

// 对应的纹理坐标为 TEXTURE_NO_ROTATION,并且 true 的参数表示进行垂直上下翻转
float[] flipTexture = TextureRotationUtil.getRotation(Rotation.NORMAL, false, true);
mGLTextureFlipBuffer.put(flipTexture).position(0);

在第一个滤镜绘制时,使用的是参数传递过来的顶点坐标和纹理坐标,中间部分的滤镜使用的是 mGLTextureBuffer 纹理坐标,它对应的纹理坐标数组为 TEXTURE_NO_ROTATION

在前面讲到过,GPUImage 的纹理坐标原点是位于左上角的,所以使用 TEXTURE_NO_ROTATION 的纹理坐标实质上是将图像进行了上下翻转,两次调用TEXTURE_NO_ROTATION纹理坐标时,又将图像复原了,这也就可以解释为什么滤镜个数为偶数时,需要使用 mGLTextureFlipBuffer 纹理坐标将图像再进行一次翻转,而 mGLTextureBuffer 纹理坐标不需要了。

当明白了 GPUImageFilter 和 GPUImageFilterGroup 的实现之后,再去看具体的 Renderer 的代码就明了多了。

onSurfaceCreatedonSurfaceChanged 方法中分别对滤镜进行初始化以及设定宽、高,在 onDrawFrame 方法中调用具体的绘制。

当切换滤镜时,先将上一个滤镜销毁掉,然后初始化新的滤镜并设定宽、高。

				final GPUImageFilter oldFilter = mFilter;
                mFilter = filter;
                if (oldFilter != null) {
                    oldFilter.destroy();
                }
                mFilter.init();
                GLES20.glUseProgram(mFilter.getProgram());
                mFilter.onOutputSizeChanged(mOutputWidth, mOutputHeight);

图像拍摄保存处理

在 GPUImage 中相机的拍摄是调用 Camera 的 takePicture 方法,在该方法中返回相机采集的原始图像数据,然后再对该数据进行一遍滤镜处理后并保存。

调用的最后都是通过 glReadPixels 方法将处理后的图像读取出来,并保存为 Bitmap 。

    private void convertToBitmap() {
        int[] iat = new int[mWidth * mHeight];
        IntBuffer ib = IntBuffer.allocate(mWidth * mHeight);
        mGL.glReadPixels(0, 0, mWidth, mHeight, GL_RGBA, GL_UNSIGNED_BYTE, ib);
        int[] ia = ib.array();
		// glReadPixels 读取的内容是上下翻转的,要处理一下
        for (int i = 0; i < mHeight; i++) {
            for (int j = 0; j < mWidth; j++) {
                iat[(mHeight - i - 1) * mWidth + j] = ia[i * mWidth + j];
            }
        }
        mBitmap = Bitmap.createBitmap(mWidth, mHeight, Bitmap.Config.ARGB_8888);
        mBitmap.copyPixelsFromBuffer(IntBuffer.wrap(iat));
    }

小结

对 GPUImage 的分析以及滤镜架构的设计大致就是这样了,这些都还不是它的精华啦,重要的还是它的那些着色器脚本,从那些着色器脚本中学会如果通过 GLSL 去实现图像处理算法。

原创文章,转载请注明来源:    OpenGL 之 GPUImage 源码分析