通过本文你将学习到滤镜的原理,以及一些常用滤镜的实现方式。
关于滤镜大家应该都不陌生,滤镜功能广泛应用
于相机、图库、短视频等应用,如抖音,看一下抖音的滤镜界面
可以看到,抖音提供了很多炫酷滤镜。
无意间看到这篇文章
当一个 Android 开发玩抖音玩疯了之后(二)
里面介绍了5种抖音滤镜实现,不过因为还没涉及动画和相机预览,所以这一节我们就拿一张静态图片来实现滤镜功能。
我们最终要实现的滤镜效果如下:
继续阅读本文,需要你对OpenGL有一定了解,
如果对OpenGL不熟悉,请先阅读以下文章:
《OpenGL从入门到放弃01 》一些基本概念
《OpenGL从入门到放弃02 》GLSurfaceView和Renderer
《OpenGL从入门到放弃03 》相机和视图
《OpenGL从入门到放弃04 》画一个长方形
《OpenGL从入门到放弃05 》着色器语言
《OpenGL从入门到放弃06 》纹理图片显示
进入正题
一、原理
基本色调处理
在OpenGL中,颜色是用包含四个浮点的向量vec4表示,四个浮点分别表示RGBA四个通道,取值范围为0.0-1.0。
我们先读取图片每个像素的色彩值,再对读取到的色彩值进行调整,这样就可以完成对图片的色彩处理了。
1. 黑白图片:
我们应该都知道,黑白图片上,每个像素点的RGB三个通道值应该是相等的。知道了这个,将彩色图片处理成黑白图片就非常简单了。
我们直接取出像素点的RGB三个通道,相加然后除以3作为处理后每个通道的值就可以得到一个黑白图片了。这是常见的黑白图片处理的一种方法。
类似的还有权值方法(给予RGB三个通道不同的比例)、只取绿色通道等方式。
与之类似的,
2. 冷色调 的处理就是单一增加蓝色通道的值,
3. 暖色调 的处理是增加红绿通道的值。
还有其他复古、浮雕等处理也都差不多,大家可以自行百度。
图片模糊处理
图片模糊处理相对上面的色调处理稍微复杂一点,通常图片模糊处理是采集周边多个点,然后利用这些点的色彩和这个点自身的色彩进行计算(取平均值),得到一个新的色彩值作为目标色彩。当然,模糊处理有很多算法,类似高斯模糊、径向模糊等等,大家可以自行百度。
放大镜效果
放大镜效果相对模糊处理来说,处理过程也会相对简单一些。我们只需要将制定区域的像素点,都以需要放大的区域中心点为中心,
向外延伸其到这个中心的距离即可实现放大效果。
四分镜效果
把整张图片缩成四份,然后分别放在左上角、右上角、左下角、右下角等地方。我们可以通过改变纹理坐标实现
啰嗦那么多,show me the code ?
实战
一、修改着色器
由于这次要大改着色器,所以将着色器代码分别放到一个文件,放在assets目录下,
后缀改为glsl,然后安装as插件GLSL SUPPORT,打开即可看到语法高亮,还可以跳转方法。
1、顶点着色器
顶点着色器(公用):shader/image/filter/filter_vertex_base.glsl
uniform mat4 uMVPMatrix;//接收传入的转换矩阵
attribute vec4 aPosition;//接收传入的顶点
attribute vec2 aTexCoord;//接收传入的纹理坐标
varying vec2 vTextureCoord;//增加用于传递给片元着色器的纹理坐标
varying vec4 vPosition;//传顶点坐标给片元着色器
void main() {
gl_Position = uMVPMatrix * aPosition;//矩阵变换计算之后的位置
vPosition = uMVPMatrix * aPosition;//矩阵变换计算之后的位置
vTextureCoord = aTexCoord;
}
这里主要关注的是定义要传递给片元着色器的变量(其它的跟之前文章一样):
vTextureCoord(纹理坐标)
vPosition(顶点坐标)
varying
修饰的变量是传递给片元着色器
2、片元着色器
重点在片元着色器的处理,一个滤镜对应一个片元着色器,不同的滤镜效果在对应的着色器里面处理:
2.1 普通的片元着色器
shader/image/filter/filter_fragment_base.glsl
precision mediump float;// 声明float类型的精度为中等(精度越高越耗资源)
varying vec2 vTextureCoord;//顶点着色器传过来的纹理坐标向量
uniform sampler2D uTexture;//纹理采样器,代表一副纹理
varying vec4 vPosition; //顶点着色器把坐标传过来
void main() {
vec4 color = texture2D(uTexture, vTextureCoord);//进行纹理采样,拿到当前颜色
gl_FragColor = color;//不特殊处理
}
2.2 黑白滤镜
shader/image/filter/filter_fragment_gray.glsl
//黑白滤镜
precision mediump float;
varying vec2 vTextureCoord;
uniform sampler2D vTexture;
void main() {
vec4 nColor = texture2D(vTexture, vTextureCoord);//进行纹理采样,拿到当前颜色
float c=(nColor.r + nColor.g + nColor.b)/3.0; //黑白的处理就是将rgb通道的颜色相加再除以3,再作为rgb通道的值
gl_FragColor=vec4(c, c, c, nColor.a);
}
2.3 暖色滤镜
shader/image/filter/filter_fragment_warm.glsl
//暖色滤镜
precision mediump float;
varying vec2 vTextureCoord;
uniform sampler2D vTexture;
void main() {
vec4 nColor = texture2D(vTexture, vTextureCoord);//进行纹理采样,拿到当前颜色
gl_FragColor=nColor + vec4(0.2, 0.2, 0.0, 0.0); //暖就是多加点红跟绿
}
2.4 冷色滤镜
shader/image/filter/filter_fragment_cool.glsl
//冷色滤镜
precision mediump float;
varying vec2 vTextureCoord;
uniform sampler2D vTexture;
void main() {
vec4 nColor = texture2D(vTexture, vTextureCoord);//进行纹理采样,拿到当前颜色
vec4 deltaColor=nColor + vec4(0.0, 0.0, 0.3, 0.0); //冷就是多加点蓝
gl_FragColor=deltaColor;
}
2.5 模糊滤镜
shader/image/filter/filter_fragment_buzzy.glsl
//模糊滤镜
precision mediump float;
varying vec2 vTextureCoord;
uniform sampler2D vTexture;
void main() {
vec4 nColor = texture2D(vTexture, vTextureCoord);//进行纹理采样,拿到当前颜色
float dis = 0.01; //距离越大越模糊
nColor+=texture2D(vTexture, vec2(vTextureCoord.x-dis, vTextureCoord.y-dis));
nColor+=texture2D(vTexture, vec2(vTextureCoord.x-dis, vTextureCoord.y+dis));
nColor+=texture2D(vTexture, vec2(vTextureCoord.x+dis, vTextureCoord.y-dis));
nColor+=texture2D(vTexture, vec2(vTextureCoord.x+dis, vTextureCoord.y+dis));
nColor+=texture2D(vTexture, vec2(vTextureCoord.x-dis, vTextureCoord.y-dis));
nColor+=texture2D(vTexture, vec2(vTextureCoord.x-dis, vTextureCoord.y+dis));
nColor+=texture2D(vTexture, vec2(vTextureCoord.x+dis, vTextureCoord.y-dis));
nColor+=texture2D(vTexture, vec2(vTextureCoord.x+dis, vTextureCoord.y+dis));
nColor+=texture2D(vTexture, vec2(vTextureCoord.x-dis, vTextureCoord.y-dis));
nColor+=texture2D(vTexture, vec2(vTextureCoord.x-dis, vTextureCoord.y+dis));
nColor+=texture2D(vTexture, vec2(vTextureCoord.x+dis, vTextureCoord.y-dis));
nColor+=texture2D(vTexture, vec2(vTextureCoord.x+dis, vTextureCoord.y+dis));
nColor/=13.0; //周边13个颜色相加,然后取平均,作为这个点的颜色
gl_FragColor=nColor;
}
2.6 四分镜滤镜
shader/image/filter/filter_fragment_four.glsl
//四分镜
precision mediump float;
varying vec2 vTextureCoord;
uniform sampler2D vTexture;
void main() {
//四分镜就是把整张图片缩成四份,然后分别放在左上角、右上角、左下角、右下角等地方。我们可以通过改变纹理坐标(x和y)得到
//类似两分镜也是同理
vec2 uv = vTextureCoord;
if (uv.x <= 0.5) {
uv.x = uv.x * 2.0;
} else {
uv.x = (uv.x - 0.5) * 2.0;
}
if (uv.y <= 0.5) {
uv.y = uv.y * 2.0;
} else {
uv.y = (uv.y - 0.5) * 2.0;
}
gl_FragColor = texture2D(vTexture, uv);
}
2.7 发光滤镜
shader/image/filter/filter_fragment_light.glsl
//发光
precision mediump float;
varying vec2 vTextureCoord;
uniform sampler2D vTexture;
uniform float uTime; //应用传时间戳过来
void main() {
float lightUpValue = abs(sin(uTime / 1000.0)) / 4.0; //计算变化值,sin函数
vec4 src = texture2D(vTexture, vTextureCoord);
vec4 addColor = vec4(lightUpValue, lightUpValue, lightUpValue, 1.0);
gl_FragColor = src + addColor; //不断地添加一个颜色
}
发光滤镜利用sin函数(取值是-1到1),然后abs取正(0到1),然后除以4,最终lightUpValue 的值是0到0.25,然后给rgb通道都加上一个 lightUpValue 值,就能实现变亮和变暗的效果,像在发光。
以上是滤镜的片元着色器代码,参考Camera中的思想,新建一个着色器管理类
ShaderManager 来加载和缓存各种滤镜对应的OpenGL程序参数
3、 ShaderManager
/**
* 着色器管理,初始化一次,以后都从缓存取
*/
public class ShaderManager {
public static final int BASE_SHADER = 1; //默认
public static final int GRAY_SHADER = 2; //黑白、灰色
public static final int WARM_SHADER = 3; //暖色
public static final int COOL_SHADER = 4; //冷色
public static final int BUZZY_SHADER = 5; //模糊
public static final int FOUR_SHADER = 6; //四分镜
public static final int ZOOM_SHADER = 7; //放大
public static final int LIGHT_SHADER = 8; //发光,比较复杂一点
private static SparseArray<Param> mParamSparseArray;
public static void init(Context context) {
mParamSparseArray = new SparseArray<>();
insertParam(BASE_SHADER, GLUtil.loadFromAssetsFile(context, "shader/image/filter/filter_vertex_base.glsl")
, GLUtil.loadFromAssetsFile(context, "shader/image/filter/filter_fragment_base.glsl"));
insertParam(GRAY_SHADER, GLUtil.loadFromAssetsFile(context, "shader/image/filter/filter_vertex_base.glsl")
, GLUtil.loadFromAssetsFile(context, "shader/image/filter/filter_fragment_gray.glsl"));
insertParam(WARM_SHADER, GLUtil.loadFromAssetsFile(context, "shader/image/filter/filter_vertex_base.glsl")
, GLUtil.loadFromAssetsFile(context, "shader/image/filter/filter_fragment_warm.glsl"));
insertParam(COOL_SHADER, GLUtil.loadFromAssetsFile(context, "shader/image/filter/filter_vertex_base.glsl")
, GLUtil.loadFromAssetsFile(context, "shader/image/filter/filter_fragment_cool.glsl"));
insertParam(BUZZY_SHADER, GLUtil.loadFromAssetsFile(context, "shader/image/filter/filter_vertex_base.glsl")
, GLUtil.loadFromAssetsFile(context, "shader/image/filter/filter_fragment_buzzy.glsl"));
insertParam(FOUR_SHADER, GLUtil.loadFromAssetsFile(context, "shader/image/filter/filter_vertex_base.glsl")
, GLUtil.loadFromAssetsFile(context, "shader/image/filter/filter_fragment_four.glsl"));
insertParam(ZOOM_SHADER, GLUtil.loadFromAssetsFile(context, "shader/image/filter/filter_vertex_base.glsl")
, GLUtil.loadFromAssetsFile(context, "shader/image/filter/filter_fragment_zoom.glsl"));
insertParam(LIGHT_SHADER, GLUtil.loadFromAssetsFile(context, "shader/image/filter/filter_vertex_base.glsl")
, GLUtil.loadFromAssetsFile(context, "shader/image/filter/filter_fragment_light.glsl"));
}
public static void insertParam(int key, String vertexShaderCode, String fragmentShaderCode) {
int program = GLUtil.createProgram(vertexShaderCode, fragmentShaderCode);
// 获取顶点着色器的位置的句柄(这里可以理解为当前绘制的顶点位置)
int positionHandle = GLES20.glGetAttribLocation(program, "aPosition");
// 获取变换矩阵的句柄
int mMVPMatrixHandle = GLES20.glGetUniformLocation(program, "uMVPMatrix");
//纹理位置句柄
int mTexCoordHandle = GLES20.glGetAttribLocation(program, "aTexCoord");
//缓存OpenGL程序
Param param = new Param(program, positionHandle, mMVPMatrixHandle, mTexCoordHandle);
mParamSparseArray.append(key, param);
}
//通过key获取缓存中的OpenGL程序参数
public static Param getParam(int key) {
return mParamSparseArray.get(key);
}
/**
* 定义一些要缓存的参数
*/
public static class Param {
public Param(int program, int positionHandle, int MVPMatrixHandle, int texCoordHandle) {
this.program = program;
this.positionHandle = positionHandle;
mMVPMatrixHandle = MVPMatrixHandle;
mTexCoordHandle = texCoordHandle;
}
public int program;
//一些公用的句柄(顶点位置、矩阵、纹理坐标)
public int positionHandle;
public int mMVPMatrixHandle;
public int mTexCoordHandle;
}
}
在onSurfaceCreated中通过调用 init
方法,即可完成所有滤镜对应的着色器的加载,然后切换滤镜只需要获取对应的缓存的参数即可。
4、滤镜的基类 BaseFilter
不同的滤镜效果,只是在片元着色器上会有大的不同,其它基本都是一样的代码,所以我们将这些公有的代码抽取出一个类,BaseFilter
/**
* 滤镜的基类
* <p>
* 加载不同的着色器就有不同滤镜效果
*/
public class BaseFilter {
private static final String TAG = "BaseFilterView";
private FloatBuffer mVertexBuffer; //顶点坐标数据要转化成FloatBuffer格式
private FloatBuffer mTexCoordBuffer;//顶点纹理坐标缓存
//当前绘制的顶点位置句柄
protected int vPositionHandle;
//变换矩阵句柄
protected int mMVPMatrixHandle;
//这个可以理解为一个OpenGL程序句柄
protected int mProgram;
//纹理坐标句柄
protected int mTexCoordHandle;
//变换矩阵,提供set方法
private float[] mvpMatrix = new float[16];
//纹理id
protected int mTextureId;
public void setMvpMatrix(float[] mvpMatrix) {
this.mvpMatrix = mvpMatrix;
}
private Context mContext;
private Bitmap mBitmap;
public BaseFilter(Context context, Bitmap bitmap) {
mContext = context;
this.mBitmap = bitmap;
//初始化Buffer、Shader、纹理
initBuffer();
initShader();
initTexture();
}
//数据转换成Buffer
private void initBuffer() {
float vertices[] = new float[]{
-1, 1, 0,
-1, -1, 0,
1, 1, 0,
1, -1, 0,
};//顶点位置
float[] colors = new float[]{
0, 0,
0, 1,
1, 0,
1, 1,
};//纹理顶点数组
mVertexBuffer = GLUtil.floatArray2FloatBuffer(vertices);
mTexCoordBuffer = GLUtil.floatArray2FloatBuffer(colors);
}
/**
* 着色器
*/
private void initShader() {
//获取程序,封装了加载、链接等操作
ShaderManager.Param param = getProgram();
mProgram = param.program;
vPositionHandle = param.positionHandle;
// 获取变换矩阵的句柄
mMVPMatrixHandle = param.mMVPMatrixHandle;
//纹理位置句柄
mTexCoordHandle = param.mTexCoordHandle;
}
/**
* 滤镜子类重写这个方法,加载不同的OpenGL程序
*
* @return
*/
protected ShaderManager.Param getProgram() {
return ShaderManager.getParam(ShaderManager.BASE_SHADER);
}
protected int initTexture() {
Log.d(TAG, "initTexture: start");
int textures[] = new int[1]; //生成纹理id
GLES20.glGenTextures( //创建纹理对象
1, //产生纹理id的数量
textures, //纹理id的数组
0 //偏移量
);
mTextureId = textures[0];
//绑定纹理id,将对象绑定到环境的纹理单元
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureId);
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_NEAREST);//设置MIN 采样方式
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);//设置MAG采样方式
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);//设置S轴拉伸方式
GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D,
GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);//设置T轴拉伸方式
if (mBitmap == null) {
Log.e(TAG, "initTexture: mBitmap == null");
return -1;
}
//加载图片
GLUtils.texImage2D( //实际加载纹理进显存
GLES20.GL_TEXTURE_2D, //纹理类型
0, //纹理的层次,0表示基本图像层,可以理解为直接贴图
mBitmap, //纹理图像
0 //纹理边框尺寸
);
Log.d(TAG, "initTexture: end,mTextureId=" + textures[0]);
return textures[0];
}
public void draw() {
// 将程序添加到OpenGL ES环境
GLES20.glUseProgram(mProgram);
/**设置数据*/
// 启用顶点属性,最后对应禁用
GLES20.glEnableVertexAttribArray(vPositionHandle);
GLES20.glEnableVertexAttribArray(mTexCoordHandle);
//设置三角形坐标数据(一个顶点三个坐标)
GLES20.glVertexAttribPointer(vPositionHandle, 3,
GLES20.GL_FLOAT, false,
3 * 4, mVertexBuffer);
//设置纹理坐标数据
GLES20.glVertexAttribPointer(mTexCoordHandle, 2,
GLES20.GL_FLOAT, false,
2 * 4, mTexCoordBuffer);
// 将投影和视图转换传递给着色器,可以理解为给uMVPMatrix这个变量赋值为mvpMatrix
GLES20.glUniformMatrix4fv(mMVPMatrixHandle, 1, false, mvpMatrix, 0);
//设置使用的纹理编号
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
//绑定指定的纹理id
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mTextureId);
/** 绘制三角形,三个顶点*/
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4);
// 禁用顶点数组(好像不禁用也没啥问题)
GLES20.glDisableVertexAttribArray(vPositionHandle);
GLES20.glDisableVertexAttribArray(mTexCoordHandle);
}
public void onDestroy() {
GLES20.glDeleteProgram(mProgram);
mProgram = 0;
}
}
滤镜的基类代码还是比较清晰的,跟上一篇加载纹理图片基本一样,这里要注意的地方是
initShader方法里面通过调用getProgram方法,从缓存中获取OpenGL程序的参数,然后取出对应的句柄;
子类通过重写getProgram方法这个方法,返回不同的着色器参数,就实现不同滤镜效果
5、黑白滤镜 GrayFilter
/**
* 黑白滤镜比较好处理
*/
public class GrayFilter extends BaseFilter {
public GrayFilter(Context context, Bitmap bitmap) {
super(context, bitmap);
}
@Override
protected ShaderManager.Param getProgram(){
return ShaderManager.getParam(ShaderManager.GRAY_SHADER);
}
}
通过对基类的封装,子类要实现滤镜就相当清晰了,只要继承BaseFilter,重写
getProgram 方法,返回该滤镜对应的 Param
即可,冷速调、暖色调、模糊、四分镜都是类似处理,简单返回不同的Param即可,而对于发光滤镜,涉及到新的参数,看一下实现
6、发光滤镜 LightFilter
/**
* 发光滤镜
*/
public class LightFilter extends BaseFilter {
private int uTimeHandle = 0;
private long startTime= 0;
public LightFilter(Context context, Bitmap bitmap) {
super(context, bitmap);
startTime = System.currentTimeMillis();
uTimeHandle = GLES20.glGetUniformLocation(mProgram,"uTime");
}
@Override
protected ShaderManager.Param getProgram(){
return ShaderManager.getParam(ShaderManager.LIGHT_SHADER);
}
@Override
public void draw() {
super.draw();
//不断更新时间
GLES20.glUniform1f(uTimeHandle, (System.currentTimeMillis() - startTime));
}
}
其实也不难,在构造方法中获取uTime
的句柄(因为uTime
句柄不是共有的句柄),然后在draw
方法中不断传新的值过去即可,然后着色器根据这个值的变化做处理,着色器的代码上面有,没印象可以往回翻一下。
7、切换模式
上面着色器代码和滤镜基类,子类都已经准备好了,接下来就差滤镜的切换显示了。
具体代码见 com.lanshifu.opengldemo.image.FilterRenderer
这个类,下面分成几个步骤说一下
7.1 onSurfaceCreated 初始化
在onSurfaceCreated 中
- 初始化ShaderManager,预加载各种着色器,缓存起来
- 创建一个Bitmap
- 创建BaseFilter,传bitmap过去
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
//着色器初始化,缓存操作
ShaderManager.init(mContext);
try {
mBitmap = BitmapFactory.decodeStream(mContext.getResources().getAssets().open("picture.png"));
} catch (IOException e) {
e.printStackTrace();
}
//暂时传Bitmap过去,后面涉及到相机再修改一下
mFilterView = new BaseFilter(mContext, mBitmap);
// 设置默认背景颜色
GLES20.glClearColor(1.0f, 0.0f, 0, 1.0f);
GLES20.glEnable(GLES20.GL_TEXTURE_2D);
}
7.2 onSurfaceChanged 计算变换矩阵,没变化
7.3 点击事件
点击了不同的滤镜按钮,调用
setType方法设置当前滤镜类型,并将mFilterChange
标志位设置为true,这样在onDrawFrame
中判断这个这个标志位true,调用updateFilterView
方法更新当前的FilterView
boolean mFilterChange = false;
int mFilterType;
public void setType(int filterType) {
if (this.mFilterType == filterType) {
Log.d(TAG, "setType: this.mFilterType == mFilterType");
return;
}
this.mFilterType = filterType;
mFilterChange = true;
}
7.4 onDrawFrame
调用updateFilterView
,然后调用 mFilterView.draw();
public void onDrawFrame(GL10 gl) {
if (mFilterChange) {
updateFilterView();
}
// Redraw background color 重绘背景
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT | GLES20.GL_DEPTH_BUFFER_BIT);
mFilterView.setMvpMatrix(mMVPMatrix);
mFilterView.draw();
}
7.5 updateFilterView
void updateFilterView() {
mFilterView = null;
switch (this.mFilterType) {
case ShaderManager.GRAY_SHADER:
mFilterView = new GrayFilter(mContext, mBitmap);
break;
case ShaderManager.BASE_SHADER:
mFilterView = new BaseFilter(mContext, mBitmap);
break;
case ShaderManager.WARM_SHADER:
mFilterView = new WarmFilter(mContext, mBitmap);
break;
case ShaderManager.COOL_SHADER:
mFilterView = new CoolFilter(mContext, mBitmap);
break;
case ShaderManager.BUZZY_SHADER:
mFilterView = new BuzzyFilter(mContext, mBitmap);
break;
case ShaderManager.FOUR_SHADER:
mFilterView = new FourFilter(mContext, mBitmap);
break;
case ShaderManager.ZOOM_SHADER:
mFilterView = new ZoomFilter(mContext, mBitmap);
break;
case ShaderManager.LIGHT_SHADER:
mFilterView = new LightFilter(mContext, mBitmap);
break;
default:
mFilterView = new BaseFilter(mContext, mBitmap);
break;
}
mFilterChange = false;
}
到此,整个滤镜功能的实现流程已经分析完了,总结一下:
- 修改着色器,增加不同的滤镜对应的片元着色器
- 用 ShaderManager 管理着色器跟OpenGL程序,缓存起来(提高切换滤镜的性能)
- 创建滤镜基类
- 不同滤镜类继承滤镜基类,加载各自的滤镜着色器
- 点击切换滤镜
如果这篇文章没看懂,那么需要先看之前的几篇文章,在文章开头有。
也可以点击源码clone项目学习。
源码仅供参考
下一篇将介绍动画实现,敬请期待。