26、Camera 与 OpenGL ES:GLSurfaceView集成、纹理绑定与更新、GPU滤镜实现、实时特效渲染

说实话,Camera 和 OpenGL ES 的结合,是 Android 相机开发里最硬核、也最有趣的一块。你想想看,Camera 给我们的原始数据,说白了就是一帧一帧的 YUV 或 JPEG 字节流。但我们要做美颜、做滤镜、做实时特效,总不能拿 CPU 去一像素一像素地算吧?那性能根本扛不住。

这时候 GPU 就派上用场了。OpenGL ES 就是操作 GPU 的接口。我当年第一次把 Camera 预览画面渲染到 GLSurfaceView 上时,那种「哇,画面动起来了」的感觉,至今还记得。嗯,咱们今天就把这条路走一遍。

GLSurfaceView 集成:把 Camera 画面搬上 GPU 画布

GLSurfaceView 是 Android 官方提供的一个 View,它内部管理了一个 EGL 上下文和一个渲染线程。我们不需要自己创建 GL 线程,只需要实现 Renderer 接口就行。

基本流程是这样的:

  1. 在布局里放一个 GLSurfaceView
  2. 设置 setEGLContextClientVersion(2) —— 我建议用 2.0 或 3.0
  3. 实现 Renderer 的三个回调:onSurfaceCreatedonSurfaceChangedonDrawFrame
  4. onSurfaceCreated 里初始化纹理、编译着色器
  5. onDrawFrame 里绑定 Camera 纹理并绘制

核心要点:GLSurfaceView 的渲染线程和 Camera 的回调线程不是同一个。Camera 的 onPreviewFrameonImageAvailable 跑在 Camera 线程,而 onDrawFrame 跑在 GL 线程。跨线程传递纹理数据时,一定要做同步。

我个人习惯用 SurfaceTexture 来桥接 Camera 和 OpenGL。Camera 把数据输出到 SurfaceTexture,OpenGL 从 SurfaceTexture 里取纹理。这样避免了手动拷贝 YUV 数据的麻烦。

// 初始化 SurfaceTexture
SurfaceTexture surfaceTexture = new SurfaceTexture(textureId);
surfaceTexture.setOnFrameAvailableListener(listener);

// 设置 Camera 预览输出
Camera camera = Camera.open();
camera.setPreviewTexture(surfaceTexture);
camera.startPreview();

// 在 onDrawFrame 中更新纹理
surfaceTexture.updateTexImage();
surfaceTexture.getTransformMatrix(matrix);

纹理绑定与更新:从 SurfaceTexture 到 GPU 纹理

这里有个关键点:SurfaceTexture 内部维护了一个 GLConsumer,它会把 Camera 的帧数据绑定到一个外部纹理(External Texture)上。这种纹理类型是 GLES11Ext.GL_TEXTURE_EXTERNAL_OES,不是普通的 GL_TEXTURE_2D

为什么用外部纹理?因为 Camera 数据是流式的、不断更新的。外部纹理允许 GPU 直接读取最新的帧,而不需要每次都重新上传纹理数据。性能好很多。

注意:外部纹理的采样器在着色器里要声明为 samplerExternalOES,并且需要启用扩展 #extension GL_OES_EGL_image_external : require。我刚开始做的时候忘了加这个扩展,画面一片黑,查了半天才发现。

纹理更新的时机也很讲究。你不能在 onDrawFrame 里无脑调用 updateTexImage()。正确的做法是:

  • onFrameAvailable 回调里设置一个标志位
  • onDrawFrame 里检查标志位,如果为 true 才更新纹理
  • 更新完后清除标志位

我曾经遇到过一个问题:如果 onDrawFrame 的调用频率比 Camera 帧率快,连续调用 updateTexImage() 会导致纹理被重复更新,画面出现撕裂。后来加了一个 AtomicBoolean 做同步,问题就解决了。

GPU 滤镜实现:着色器才是灵魂

滤镜的本质,就是对着色器里的像素颜色做数学变换。OpenGL ES 2.0 之后,我们用 GLSL 写片段着色器(Fragment Shader)来实现各种效果。

一个最简单的滤镜——灰度滤镜,它的片段着色器长这样:

#extension GL_OES_EGL_image_external : require
precision mediump float;
varying vec2 vTexCoord;
uniform samplerExternalOES sTexture;

void main() {
    vec4 color = texture2D(sTexture, vTexCoord);
    float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
    gl_FragColor = vec4(gray, gray, gray, color.a);
}

你看,核心就一行:用亮度公式把 RGB 转成灰度值。但实际项目中,滤镜要复杂得多。我整理了几种常见滤镜的实现思路:

滤镜类型 核心算法 性能开销
灰度 点乘亮度系数 极低
怀旧/暖色 颜色查找表(LUT)
美颜磨皮 双边滤波 + 高斯模糊 中高
边缘检测 Sobel 算子卷积
马赛克 像素块取均值

我个人最推荐用 LUT(颜色查找表)来实现滤镜。为什么?因为 LUT 本质上是一个 64x64 或 512x512 的图片,你把原始颜色映射到目标颜色。换滤镜只需要换一张 LUT 图片,不需要重新编译着色器。我在项目里用这个方案,一套代码支持了 20 多种滤镜,上线后效果很稳定。

实时特效渲染:性能是王道

实时特效,说白了就是每一帧都要在 16ms 内完成渲染(60fps)。如果做不到,画面就会卡顿。这里有几个性能优化的关键点:

  • 减少纹理上传次数:Camera 数据不要每次都上传到 GPU,用 SurfaceTexture 的零拷贝机制
  • 避免在 GL 线程做 I/O:文件读写、网络请求统统放到子线程
  • 使用 VBO 和 VAO:顶点数据不要每帧都传,一次绑定,多次绘制
  • 合并绘制调用:能用一次 draw call 搞定的,别拆成多次

小技巧:如果你要做多级滤镜(比如先磨皮再调色再加边框),不要用多个 Pass 渲染。把多个效果写到一个片段着色器里,一次绘制完成。我试过,性能能提升 30% 以上。

还有一个容易被忽略的点:EGL 上下文切换。如果你在多个 GLSurfaceView 或后台线程里操作 OpenGL,每次切换上下文都有不小的开销。我建议所有 GL 操作都在同一个线程里完成,用 Handler 或消息队列来调度。

嗯,说到实时特效,我记得有一次做 AR 贴纸项目。用户脸上要实时叠加一个猫耳朵,需要人脸检测 + 特效渲染。人脸检测跑在 CPU 上,渲染跑在 GPU 上。两个线程之间的数据同步如果没做好,猫耳朵就会在脸上乱跳。后来我用了一个 ConcurrentLinkedQueue 来传递人脸关键点坐标,渲染线程每帧从队列里取最新的数据,问题就解决了。

SVG 流程图:Camera + OpenGL ES 渲染管线

下面这张图展示了 Camera 数据从采集到最终显示在屏幕上的完整流程。我建议你仔细看看纹理绑定和着色器处理这两个环节,它们是整个管线的核心。

Camera + OpenGL ES 实时渲染管线 Camera HAL 采集原始帧 SurfaceTexture 零拷贝桥接 外部纹理 GL_TEXTURE_EXTERNAL_OES 着色器 滤镜/特效 GLSurfaceView 显示 onDrawFrame 每帧调用 updateTexImage() 获取最新帧 + 变换矩阵 性能关键点 ① 避免每帧上传纹理数据 ② 合并多个特效到一个着色器 ③ 使用 VBO/VAO 减少顶点传输 ④ 跨线程同步用 AtomicBoolean

从这张图你能看到,整个管线其实不复杂。Camera 采集 → SurfaceTexture 桥接 → 外部纹理绑定 → 着色器处理 → 屏幕显示。每一步都有它的坑,但只要你理解了数据流的方向,调试起来就有思路了。

最后说一句:OpenGL ES 的学习曲线确实陡,但一旦你掌握了纹理和着色器这两个核心概念,剩下的就是数学和经验的积累了。我做了这么多年,每次遇到新的特效需求,第一反应还是「这个效果能不能用着色器实现?」—— 答案通常是「能」。


公众号:蓝海资料掘金营,微信deep3321