做到第十六天,我给自己定的规矩是:每天必须把一个渲染环节彻底讲明白。今天轮到 GPUImageTwoInputFilter,这是我在 Android 美颜相机里绕过最多、也用得最顺手的一个滤镜基类。如果你一直在用 GPUImageFilter 这种单输入滤镜,那你可能已经碰到过这种需求:想让磨皮只作用于皮肤区域、想把 LUT 颜色查找表和原图在同一个片段着色器里逐像素计算、想叠加一层漏光素材……这些需求单输入滤镜都做不了,换上双输入滤镜之后,整个问题的打开方式都不一样了。
本文不打算从头讲 OpenGL ES 基础,而是站在“已经能跑通单输入滤镜、开始做复杂美颜效果”的节点上,把 GPUImageTwoInputFilter 的实现逻辑、接入方法、调试思路一次说透。全文代码基于我项目里实际在用的 GPUImage Android 版本(Cybertk 那一系),不同发行版细节可能略有差别,但核心思路完全一致。
1. 单输入滤镜的边界:什么场景逼迫我换用 TwoInputFilter
1.1 美颜相机里绕不开的三类“双图运算”
我最早接触 GPUImageFilter 的时候,觉得它挺完美的:一个采样器 inputImageTexture,传入一张纹理,shader 里做处理,输出一张新纹理。直到开始做真正意义上的美颜管线,才发现单输入只能解决“对一张图做变换”这一类问题。
第一类是磨皮。今天大部分磨皮算法不管怎么包装,底子上都离不开“原图”和“模糊图”两张图。要用原图减去模糊图得到高频细节,再把处理后细节叠加回原图;或者简单一点,直接把原图和模糊图按权重混合。这种逐像素运算里,原图和模糊图必须同时出现在同一个 shader 里,你才能对同一个坐标上的两个颜色值做操作。单输入滤镜做不到,你只拿得到一张图。
第二类是 LUT 颜色映射。LUT(颜色查找表)本质上是一张预设好的纹理,shader 根据原图某个像素的 RGB 值去查找表里查找映射后的颜色。这个过程也是原图 + LUT 两张纹理的逐像素协作。
第三类是遮罩控制。比如我只想让腮红区域的皮肤轻度变白、让嘴唇饱和度更高,或者让头发区域做锐化而背景保持原样。遮罩本身就是一张灰度纹理,shader 里要根据遮罩的灰度值决定处理强度。这又是双输入。
无论是哪一种,共性都是:你需要在同一个片段着色器里,同时拿到两张纹理进行运算。GPUImageTwoInputFilter 就是为这个场景设计的基类。
1.2 用 FilterGroup 串联为什么不是最优解
可能有朋友会问:我用 FilterGroup 把两个滤镜串联起来,先在第一个滤镜里生成模糊结果,再在第二个滤镜里混合,不也能实现吗?
能实现,但代价很大。GPUImageFilterGroup 本质上是一条串行链,前一个滤镜的输出纹理作为后一个滤镜的输入。原图先进高斯模糊,产生模糊纹理;然后原图和模糊纹理要想在同一个 shader 里融合,模糊纹理就得作为第二输入传给下一个滤镜——而 FilterGroup 里的滤镜默认只接收上游那一个输入。你要么绕道把中间结果存下来再手动塞进去,要么就得牺牲一次额外的渲染往返,把模糊结果先画到帧缓冲里,再作为纹理传给下一个滤镜。
每多一次中间渲染,就多一次全屏纹理的读写开销,在 1080P 甚至更高分辨率下,这个开销在低端机上是肉眼可见的卡顿。更重要的是,串联逻辑把“两图像素级运算”拆成了“两阶段运算”,中间会丢失很多精度和灵活性。比如磨皮里想做“原图减去模糊图得到细节”这种运算,你就必须先拿到两张完整纹理,再在 shader 里同时采样。FilterGroup 解决不了这种关系,TwoInputFilter 才是对的工具:一次 pass,两个采样器,一个 shader 就把运算做完。
2. GPUImageTwoInputFilter 源码拆解:第二张纹理是怎么被送进着色器的
2.1 初始化阶段:链接之后拿到 attribute / uniform
先用我实际项目里的实现来讲。GPUImageTwoInputFilter 继承自 GPUImageFilter,核心改动在三个地方:自定义顶点着色器、新增第二个纹理坐标 attribute、新增第二个纹理 uniform。
顶点着色器不再是默认的单纹理坐标版本,而是像下面这样:
attribute vec4 position; attribute vec4 inputTextureCoordinate; attribute vec4 inputTextureCoordinate2; varying vec2 textureCoordinate; varying vec2 textureCoordinate2; void main() { gl_Position = position; textureCoordinate = inputTextureCoordinate.xy; textureCoordinate2 = inputTextureCoordinate2.xy; }为什么必须替换顶点着色器?因为默认的 GPUImageFilter 顶点着色器只声明了一个 varying 纹理坐标,双输入需要两个 varying 把两张图的坐标分别传到片段着色器。你可以不换顶点着色器,直接在片段着色器里硬编码一个坐标,但那会失去通用性,也无法随顶点数据一起做纹理翻转等调整。
onInit 阶段是关键。程序链接完成之后,要拿到第二个纹理坐标 attribute 的位置和第二个纹理 uniform 的位置:
@Override public void onInit() { super.onInit(); mFilterSecondTextureCoordinateAttribute = GLES20.glGetAttribLocation(getProgram(), "inputTextureCoordinate2"); mFilterInputTextureUniform2 = GLES20.glGetUniformLocation(getProgram(), "inputImageTexture2"); GLES20.glEnableVertexAttribArray(mFilterSecondTextureCoordinateAttribute); }这里有个很容易被忽略的细节:glEnableVertexAttribArray。GLSL 里的 attribute 默认是关闭状态,你不 enable 它,后面即使把坐标数组 pointer 设置好了,顶点着色器也读不到第二个坐标,第二张纹理采样时所有像素都会落到同一个坐标上,表现通常是整张图花掉或者边角拉伸。很多同学自定义双输入滤镜失败,就栽在这一行没写。
还要注意,onInit 里必须先调用 super.onInit(),让父类把第一个纹理的 attribute、uniform 都准备好,然后再去拿第二个纹理的 attribute/uniform。顺序反了或者漏了 super,第一张纹理就丢了,画面直接黑屏。
2.2 绘制阶段:纹理单元、坐标数组和 uniform 的绑定顺序
到了 onDraw,双输入的精髓才真正体现出来。我项目里的实现大致是这样:
@Override public void onDraw() { super.onDraw(); GLES20.glActiveTexture(GLES20.GL_TEXTURE2); GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, mFilterSourceTexture2); GLES20.glUniform1i(mFilterInputTextureUniform2, 2); GLES20.glVertexAttribPointer(mFilterSecondTextureCoordinateAttribute, 2, GLES20.GL_FLOAT, false, 0, mSecondTextureCoordinateBuffer); }拆开看,其实做了三件事。
第一,激活纹理单元 GL_TEXTURE2。OpenGL ES 里你有多个纹理单元,sampler2D uniform 的数字就是纹理单元编号。GPUImage 的默认流程里,第一张图片绑定在 GL_TEXTURE0 上,super.onDraw() 里会激活 GL_TEXTURE0、绑定第一纹理、把 uniform 设置为 0。所以我们在 super.onDraw() 之后,再去激活 GL_TEXTURE2,就不会破坏第一张图片的状态。
第二,绑定第二张纹理到激活的纹理单元,并把 uniform 的值设置为对应的纹理单元编号。glUniform1i(mFilterInputTextureUniform2, 2) 里的 2 不是随便写的,它必须和 glActiveTexture(GLES20.GL_TEXTURE2) 里的 2 保持一致。你改成 GL_TEXTURE1 就绑 1,改成 GL_TEXTURE4 就绑 4,关键是两处数字对得上。很多黑屏的根因就是这个数字不一致:激活了一个纹理单元,uniform 指向另一个,等于让采样器去空纹理里取值。
第三,设置第二个坐标数组。这个 buffer 里存的是第二张纹理的顶点坐标,默认情况下和第一张图的坐标一致,就是铺满整个渲染区域的一组 (0,0)(1,0)(0,1)(1,1) 类坐标。但如果你要做纹理偏移、局部遮罩映射、或者故意让第二张图只覆盖部分区域,就在这里动手脚。我后面讲遮罩玩法时,这个 buffer 的灵活性就体现出来了。
2.3 第二纹理从哪来:Bitmap 还是 FBO 纹理 id
双输入滤镜的第二个输入,数据源头通常有两种。
一种是 Bitmap。比如漏光素材、颗粒噪点、LUT 查找表,这些是静态图片,你在 Java 层解析成 Bitmap,然后通过 setBitmap 之类的方法传给滤镜。内部流程是:生成一个纹理 id,glTexImage2D 把 Bitmap 像素上传到显存,之后每帧渲染时直接绑定这个纹理 id 即可。
另一种是 FBO(帧缓冲对象)的纹理 id。磨皮里的模糊图、局部遮罩,往往不是现成的 Bitmap,而是另外一组滤镜渲染出来的结果。这种情况下绝对不能把 GPU 纹理先读回 Bitmap、再重新上传,那样性能会崩掉。正确做法是让上游滤镜在离屏 FBO 上渲染,拿到它的 color attachment 纹理 id,直接作为第二输入传进来。整条管线里所有中间结果都停留在 GPU 显存中,没有 CPU 回读的环节。
我最初做柔焦磨皮时偷懒,把模糊结果用 glReadPixels 读回 Bitmap 再 setBitmap 进去,结果帧率直接从 60 掉到 20 出头。后来才意识到,FBO 纹理 id 的传递方式才是双输入滤镜的正道。
3. 实战:做一个原图 + 高斯模糊的柔焦磨皮滤镜
3.1 滤镜类骨架与 shader 设计
理解了基类机制,下面做一个能直接用的滤镜。目标效果是“柔焦磨皮”:保留原图基本结构,同时让皮肤纹理变得平滑柔和,实现方式是原图和它自己的高斯模糊版本做一个混合。
先写滤镜类:
public class SmoothSkinFilter extends GPUImageTwoInputFilter { private int uniformMixPercentLocation; private float mixPercent = 0.6f; public SmoothSkinFilter() { super(DEFAULT_VERTEX_SHADER, SMOOTH_FRAGMENT_SHADER); } @Override public void onInit() { super.onInit(); uniformMixPercentLocation = GLES20.glGetUniformLocation(getProgram(), "mixPercent"); } @Override public void onDraw() { super.onDraw(); GLES20.glUniform1f(uniformMixPercentLocation, mixPercent); } public void setMixPercent(float percent) { mixPercent = percent; } }构造器里传入的顶点着色器,直接用前面那个双输入版本;片段着色器是双输入的核心,我给出一个简化版:
precision highp float; varying vec2 textureCoordinate; varying vec2 textureCoordinate2; uniform sampler2D inputImageTexture; uniform sampler2D inputImageTexture2; uniform float mixPercent; void main() { vec4 sourceColor = texture2D(inputImageTexture, textureCoordinate); vec4 blurColor = texture2D(inputImageTexture2, textureCoordinate2); vec3 smoothColor = mix(sourceColor.rgb, blurColor.rgb, mixPercent); gl_FragColor = vec4(smoothColor, sourceColor.a); }这个 shader 的逻辑一句话就能讲清:同一个像素位置,从原图取 sourceColor,从模糊图取 blurColor,按 mixPercent 线性混合。mixPercent 越大,画面越柔和,但同时细节丢失越明显。实际体验下来 0.5~0.7 之间比较安全,超过 0.75 眼睛和头发边缘就开始发虚。
这种“原图直接混合模糊图”是非常初级的柔焦,真做产品级磨皮还要加边缘保留、肤色检测、细节叠加这些步骤。但作为 TwoInputFilter 的入门案例,它把“双输入”的链路完整跑通了:你有两张纹理,在一个 shader 里做任意逐像素运算,这句话比任何源码分析都有用。
3.2 把模糊结果通过 FBO 接到第二输入
滤镜类写完,接下来的问题是:第二张“模糊图”怎么产生,又怎么送到 SmoothSkinFilter 里?
我的做法是用一个 GPUImageGaussianBlurFilter 先把原图做高斯模糊,再把它的输出纹理 id 作为第二输入。
流程拆成四步。
第一步,创建离屏渲染目标。GLSurfaceView 的渲染线程里,生成一个 FBO,并给 FBO 附上一张和原图同尺寸的颜色纹理作为 color attachment。这张纹理之后就是模糊结果要待的地方。
第二步,把原图纹理传给高斯模糊滤镜,让它渲染到上面的 FBO 上。也就是让高斯模糊滤镜的目标 framebuffer 不是屏幕,而是那个离屏 FBO。
第三步,从 FBO 里取出 color attachment 纹理 id。用 glGetIntegerv(GL_FRAMEBUFFER_BINDING) 或者直接保存你生成 FBO 时创建的纹理 id 即可。这个纹理 id 不需要任何拷贝或回读,它就是显存里的一张原生纹理。
第四步,把纹理 id 传给 SmoothSkinFilter:
int blurTextureId = blurFilter.getFrameBufferTexture(); smoothFilter.setSecondTexture(blurTextureId);setSecondTexture 内部就是把 mFilterSourceTexture2 赋值为传入的纹理 id,之后再 onDraw 时,第二输入就和原图同步进入了同一个 shader。
这套流程的关键认知是:GPUImageFilterGroup 的默认串联管线只支持“单输入对单输入”,当你想在中间插入一个双输入滤镜时,得自己把上游滤镜的渲染输出接到下游滤镜的第二个输入上。本质上是把过滤链从“线性管道”改成了“分叉汇合”,而 FBO 就是那个汇合点。
3.3 从参数到效果的调优思路
柔焦磨皮接进预览后,我做的第一件事不是调 mixPercent,而是验证两张图是否严格对齐。测试方法粗暴但有效:把 shader 临时改成只输出第二张纹理,看它和原图有没有错位、翻转、拉伸。只有第二张图和第一张完全重合,后面混合才有意义。
对齐没问题后,再从三个方向调效果。
第一个是模糊半径。高斯模糊半径太小,模糊图平滑度不够,磨皮效果很弱;半径太大,图糊成一团,混合后会出现光晕感和肉色弥散。用 GPUImageGaussianBlurFilter 时,我一般从 4.0 开始试,多档预览对比后选 6.0 左右作为默认值。
第二个是混合权重。这里可以做一个联动:权重高、模糊半径小,适合皮肤瑕疵少、想要“通透感”的场景;权重低、模糊半径大,适合大面积皮肤粗糙的场景。产品上可以把这两个参数绑成一个“磨皮强度”的滑杆,而不是让用户分别调。
第三个是边缘保护。原图和模糊图直接混合,边缘一定会发虚。我的优化做法是引入第三张图——原图的高频细节图,用“原图 + (原图 - 模糊图) × 系数”的方式把细节加回来。这时候你的滤镜就不该叫 TwoInput 了,而应该升级成 GPUImageThreeInputFilter,三个输入分别为原图、模糊图、高频图。从 TwoInput 到 ThreeInput 的跨越其实很小,理解了双输入,三输入就是在 onInit 和 onDraw 里再多做一遍同样的绑定动作。
4. 双输入滤镜排雷手册:黑屏、错位、闪烁的定位链路
4.1 黑屏先查纹理单元,再查 uniform
双输入滤镜最常见的问题就是黑屏。很多人的第一反应是 shader 写错了,实际上 shader 语法错误在编译期就会被告警,真正跑起来黑屏绝大多数出在纹理绑定逻辑。
我排查黑屏固定按三个步骤走。
第一步,确认第二个纹理 id 不是 0 也不是 -1。纹理 id 为 0 时,glBindTexture 绑定的是一张空纹理,采样出来是黑色。检查 setSecondTexture 是否真的被调用过,以及上游 FBO 是否成功生成了颜色纹理。
第二步,检查 glActiveTexture 和 glUniform1i 的编号是否一致。我踩过一次很隐蔽的坑:GL_TEXTURE2 的常量值是 33994,uniform 里写 2,两个数字在代码里看起来“不一样”,其实 OpenGL 正是用 2 代表 GL_TEXTURE2 的。但如果你在 glActiveTexture 里用了 GL_TEXTURE1,uniform 里却保留 2,黑屏就出现了。统一写法,显式写成同一个宏或同一个数字,别一处写常量一处写数字。
第三步,检查 onDraw 里是否有别的滤镜或渲染逻辑改动了当前激活的纹理单元。比如有的滤镜内部也会调用 glActiveTexture(GL_TEXTURE1),你的双输入滤镜如果在自己 onDraw 之前依赖“当前纹理单元是 GL_TEXTURE2”这个假设,就会被上游干扰。所以规范做法是像源码那样:先 super.onDraw(),再自己激活第二纹理单元,这样每一步都在显式设置状态,不依赖上下文。
4.2 纹理坐标方向不一致导致错位
错位和翻转是第二个高频坑。现象是:第二张图能显示,但内容上下颠倒、水平镜像,或者和第一张图不在同一位置。
根源在于第二张纹理的坐标数组与纹理上传方式不匹配。相机预览帧、FBO 渲染结果、Bitmap 上传这三者的“第 0 行”方向可能不同。Bitmap 的像素第一行在最上面,而 OpenGL 纹理坐标的 v 轴方向是从下往上,直接把 Bitmap 传给 glTexImage2D,第二张图在屏幕上显示时是上下颠倒的。
解决办法取决于你哪一步翻转。如果第二张图来自 Bitmap,可以在上传时用 GLES20.glPixelStorei 配合像素行序调整,或者把 shader 里第二组 varying 的 y 坐标做 UV 翻转:
textureCoordinate2 = vec2(textureCoordinate.x, 1.0 - textureCoordinate.y);如果第二张图来自 FBO 渲染结果,那么它的方向和第一张图是否一致,取决于第一张图是不是也经过了同样的 FBO 流程。最稳妥的做法是:不要靠猜,把 shader 临时改成只输出第二张纹理,贴一张有明显方向性特征的原图(我常用一张带文字的截图),看它和原图的差异,再决定在哪个环节翻转。经验是“先看差异,再改坐标,最后才加运算逻辑”。
4.3 GL 线程约束与纹理加载时机
双输入滤镜比单输入更容易暴露出 GL 线程问题,因为第二张纹理的加载时机常常和预览帧不同步。
OpenGL 的所有 GL 操作都必须在持有 EGL context 的线程里执行。GLSurfaceView.Renderer 的 onDrawFrame 就是在 GL 线程里跑的,你在主线程调 setBitmap,内部执行 glTexImage2D,这在大多数设备上可能不报错,但偶尔会出现花屏、闪烁、甚至偶发崩溃。我调试过一台老机器,只要在 onSurfaceCreated 之后从主线程上传纹理,预览画面就会间歇性闪黑,换成 GL 线程上传后症状立刻消失。
正确的做法是做一个任务队列,把所有纹理上传动作 post 到 GL 线程执行。比如:
final Bitmap bitmap = loadBitmap(); GLThread.post(new Runnable() { @Override public void run() { filter.setBitmap(bitmap); } });这里 GLThread 可以是 GLSurfaceView 持有的 GL 线程引用,或者你自己维护一个 Handler 绑定到 GL 线程。核心原则一句话:GL 资源的创建、更新、销毁,一律只在 GL 线程里做。
4.4 双输入滤镜常见故障对照表
我把实际项目里遇到过的双输入问题整理成了一张表,排查时先对照现象再动手:
| 现象 | 可能原因 | 检查点 |
|---|---|---|
| 全黑屏 | 第二纹理 uniform 与纹理单元不一致 | glActiveTexture 参数和 glUniform1i 数值 |
| 全黑屏 | 第二纹理 id 为 0 或未赋值 | 确认 setSecondTexture 是否调用、FBO 是否生成 |
| 第二张图花掉/像素错位 | 第二个 attribute 未 glEnable | onInit 里是否调用了 glEnableVertexAttribArray |
| 第二张图上下颠倒/镜像 | 纹理坐标或上传行序不一致 | shader 里 v 坐标翻转/纹理上传方向 |
| 预览闪烁/偶发黑帧 | 在主线程或非 GL 线程上传纹理 | 纹理上传动作改到 GL 线程 |
| 第二张图边缘发黑 | 非 2 的幂纹理 wrap 或 filter 设置不当 | 设置 CLAMP_TO_EDGE 与 GL_LINEAR |
| 运行一段时间后纹理泄漏 | glDeleteTexture 未调用 | 检查纹理生命周期管理和 GLContext 销毁回调 |
5. 更进一步:把第二张图变成“遮罩”,美颜只作用在需要的地方
5.1 遮罩纹理的生成与更新策略
做完柔焦磨皮,我第二个实际用的双输入场景是局部遮罩。需求很现实:磨皮强度如果作用到背景上,墙面、毛毯这些大平面也会被磨得发腻,观感非常“假”。如果能让磨皮只在人脸皮肤区域生效,效果才自然。
实现方式就是让第二张纹理变成一张灰度遮罩:白色代表需要处理的区域,黑色代表不需要处理的区域,灰色代表过渡区域。
遮罩纹理怎么生成?有两条路。
一条是离线生成。设计阶段把需要的美白/p 处理区域画好,导出成透明 PNG,运行时作为 Bitmap 上传成纹理。优点是快,缺点是不够灵活,人脸位置一变遮罩就对不上了。
另一条是运行时生成。基于人脸关键点或肤色检测结果,在 GPU 里渲染一张遮罩。比如检测到眼部周围的关键点,用一个径向渐变圆描出“眼下提亮区”;检测到人脸区域,用肤色范围判断出皮肤并赋白值。这张遮罩可以借助离屏 FBO 渲染生成,然后在双输入滤镜里和原图进行合成运算。
遮罩的更新策略很重要。每帧重新生成遮罩开销大,但人脸移动时遮罩不变也会出问题。折中方案是:遮罩 3~5 帧更新一次,或者只在人脸关键点位变化超过阈值时更新。因为遮罩是低频信息,轻微滞后人眼根本感知不到,但 CPU/GPU 开销能省下不少。
5.2 用 mask 控制磨皮强度的 shader 写法
有了遮罩纹理,双输入滤镜的 shader 逻辑和之前就不一样了。还是两张纹理输入,但运算从“均匀混合”变成了“按遮罩加权混合”:
precision highp float; varying vec2 textureCoordinate; varying vec2 textureCoordinate2; uniform sampler2D inputImageTexture; uniform sampler2D inputImageTexture2; uniform float strength; void main() { vec4 sourceColor = texture2D(inputImageTexture, textureCoordinate); vec4 blurColor = texture2D(inputImageTexture2, textureCoordinate2); float mask = texture2D(inputImageTexture2, textureCoordinate2).r; vec3 smoothColor = mix(sourceColor.rgb, blurColor.rgb, strength * mask); gl_FragColor = vec4(smoothColor, sourceColor.a); }注意这里一个很实用的细节:我用同一个采样器 inputImageTexture2 同时做两件事——在混合运算里作为模糊图,在 mask 计算里作为遮罩。技术上这要求模糊图和遮罩是同一张纹理,二者可以合成一张“灰度模糊图”,即遮罩信息编码在亮度通道里。如果模糊图和遮罩本来就不是同一张图,那就需要用到 GPUImageThreeInputFilter 的三路输入了。
灰度遮罩直接用 .r 通道作为加权值,好处是边缘天然有过渡。遮罩边缘不要做成硬边,哪怕只是 2~3 个像素的渐变过渡,磨皮结果都会自然很多。这个细节我调了很久才体会到:产品上用户不关心你的遮罩算法多高级,他们只关心边缘会不会出现明显的“交界线”。
5.3 纹理缓存与生命周期管理
双输入滤镜用熟练之后,纹理数量和滤镜个数会迅速增加。漏光纹理、LUT 纹理、遮罩纹理、模糊结果纹理……如果每个纹理都各自为政,代码很快就会失控,而且 GL 纹理泄漏很难查。我后来在项目里加了一个简单的纹理缓存工具类,思路就是 Map 加引用计数。
public class GLTextureCache { private static final Map<String, Integer> textureMap = new HashMap<>(); public static int get(String key) { return textureMap.containsKey(key) ? textureMap.get(key) : 0; } public static void put(String key, int textureId) { textureMap.put(key, textureId); } }静态素材(比如漏光、LUT)在 GLSurfaceView 的 onSurfaceCreated 里统一加载进缓存,之后每帧直接按 key 取纹理 id,不再重复上传。这么做有两个好处:一是省去了每帧 setBitmap 的开销;二是当 GLContext 因切后台被销毁重建时,onSurfaceCreated 会重新触发,缓存可以统一重建,避免纹理 id 失效导致所有滤镜黑屏。
还有一个生命周期问题:GL 纹理的删除时机通常绑定在 GLContext 销毁时。你在 onPause 或者界面退出时手动 glDeleteTexture 要格外小心,如果你的 GLSurfaceView 和滤镜链是共用 context 的,删早了其他滤镜还在引用同一纹理 id,画面就会异常。我的习惯是:项目里只保留一个唯一的纹理释放入口,在 onSurfaceDestroyed 阶段统一执行 glDeleteTextures,并且保证删完之后不再有任何滤镜引用这些纹理 id。
写在最后的几句经验
做双输入滤镜这件事,本质上就是学会把滤镜从“单张图片的修饰函数”重新理解成“两张图片之间的关系”。我现在拿到一个新的美颜需求,第一反应是拆输入:原图进来,另一张图是什么?是模糊结果、LUT、还是遮罩?想清楚这个,shader 怎么写都是顺水推舟。GPUImageTwoInputFilter 只是打通了这条路的底层机制,真正值钱的是你想让这两张图之间发生什么关系——而那个“关系”,永远在 shader 里,不在基类里。