12、Camera2与OpenGL结合:SurfaceTexture创建、纹理更新、预览帧处理、滤镜渲染

说实话,Camera2 的 API 设计得挺绕的。我刚接触那会儿,光是把预览画面显示出来就折腾了两天。但当你把 Camera2 和 OpenGL 结合起来,你会发现一个全新的世界——每一帧画面你都能掌控,想加滤镜就加滤镜,想做特效就做特效。

这节课,我们就来拆解这个组合的核心:SurfaceTexture。它就像一座桥,一头连着 Camera2 的实时数据流,另一头连着 OpenGL 的纹理管线。

12.1 SurfaceTexture 是什么?

说白了,SurfaceTexture 就是一个“生产者-消费者”模型里的中间人。Camera2 作为生产者,把每一帧图像数据塞进一个 Surface 里。OpenGL 作为消费者,从对应的纹理 ID 里把数据读出来渲染。

我习惯把 SurfaceTexture 理解成一个“会说话的纹理”。普通的纹理你得自己加载图片,而 SurfaceTexture 会自动从相机流里拿数据填充纹理。你只需要在每一帧到来时,调用 updateTexImage() 就行了。

核心流程:

  • 创建 SurfaceTexture,绑定到一个 OpenGL 纹理 ID 上
  • 把这个 SurfaceTexture 的 Surface 传给 Camera2 的 CaptureRequest
  • 每帧回调时,调用 updateTexImage() 更新纹理内容
  • 用这个纹理去做渲染、滤镜、编码等操作
Camera2 生产者 SurfaceTexture 桥梁 OpenGL 消费者 Surface 纹理ID 每帧回调 → updateTexImage() → 纹理内容更新 渲染管线 → 滤镜处理 → 显示/编码/推流

12.2 创建 SurfaceTexture 并绑定纹理

这一步很关键。你得先创建一个 OpenGL 纹理,然后把纹理 ID 传给 SurfaceTexture 的构造函数。

// 1. 生成一个 OpenGL 纹理
int[] textures = new int[1];
GLES20.glGenTextures(1, textures, 0);
int textureId = textures[0];

// 2. 绑定并设置纹理参数
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);

// 3. 创建 SurfaceTexture,传入纹理 ID
SurfaceTexture surfaceTexture = new SurfaceTexture(textureId);

我的经验:纹理参数一定要设置 GL_CLAMP_TO_EDGE,否则在某些机型上会出现边缘黑边或花屏。我曾经在小米某款机型上踩过这个坑,排查了半天才发现是纹理环绕方式的问题。

12.3 把 Surface 交给 Camera2

SurfaceTexture 内部维护了一个 Surface 对象。你通过 surfaceTexture.getSurface() 拿到它,然后传给 Camera2 的预览请求。

// 获取 Surface
Surface surface = new Surface(surfaceTexture);

// 创建 CaptureRequest.Builder
CaptureRequest.Builder previewBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
previewBuilder.addTarget(surface);

// 创建会话
cameraDevice.createCaptureSession(Arrays.asList(surface), 
    new CameraCaptureSession.StateCallback() {
        @Override
        public void onConfigured(CameraCaptureSession session) {
            session.setRepeatingRequest(previewBuilder.build(), 
                new CameraCaptureSession.CaptureCallback() {
                    @Override
                    public void onCaptureCompleted(CameraCaptureSession session, 
                                                   CaptureRequest request, 
                                                   TotalCaptureResult result) {
                        // 每帧回调
                    }
                }, null);
        }
    }, null);

嗯,这里要注意:onCaptureCompleted 回调并不代表纹理已经更新了。真正的纹理更新,需要你在 OpenGL 线程里主动调用 surfaceTexture.updateTexImage()

12.4 纹理更新:updateTexImage() 的正确姿势

为什么说“正确姿势”?因为很多人把 updateTexImage() 放在了 Camera2 的回调里,结果发现画面卡顿或者纹理不更新。

原因很简单:updateTexImage() 必须在 OpenGL 线程(也就是持有 EGL 上下文的线程)中调用。Camera2 的回调跑在 Camera 线程,跟 OpenGL 线程不是同一个。

我曾经犯过的错:直接在 Camera2 的 onCaptureCompleted 里调 updateTexImage(),结果纹理一直没更新,画面黑屏。后来才意识到,OpenGL 的操作必须在线程绑定的 EGL 上下文中执行。

正确的做法是:在 Camera2 回调里发一个消息到 OpenGL 线程的 Handler,然后在 OpenGL 线程里调用 updateTexImage()。

// Camera2 回调中
@Override
public void onCaptureCompleted(CameraCaptureSession session, 
                               CaptureRequest request, 
                               TotalCaptureResult result) {
    // 发送消息到 GL 线程
    glHandler.post(() -> {
        surfaceTexture.updateTexImage();
        // 获取变换矩阵(如果需要)
        float[] mtx = new float[16];
        surfaceTexture.getTransformMatrix(mtx);
        // 开始渲染
        renderFrame(mtx);
    });
}

12.5 预览帧处理:拿到数据做点什么

纹理更新之后,你就可以为所欲为了。常见的预览帧处理包括:

  • 截图:从纹理中读取像素数据,保存为 Bitmap
  • 人脸检测:把纹理数据传给 ML Kit 或 OpenCV
  • 录制视频:把纹理渲染到 MediaCodec 的 Surface 上
  • 推流:结合 RTMP 库,把纹理数据编码后推送

我个人最常用的是截图功能。实现起来也不复杂:

// 创建一个帧缓冲对象
int[] fbo = new int[1];
GLES20.glGenFramebuffers(1, fbo, 0);
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fbo[0]);

// 绑定纹理到帧缓冲
GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0,
                              GLES20.GL_TEXTURE_2D, textureId, 0);

// 读取像素
IntBuffer buffer = IntBuffer.allocate(width * height);
GLES20.glReadPixels(0, 0, width, height, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, buffer);

// 别忘了解绑
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);

避坑指南:glReadPixels 读取的像素是上下颠倒的。你需要做一次垂直翻转,或者用 Bitmap 的 Matrix 处理一下。我曾经因为这个原因,截出来的图全是倒的,被测试同学吐槽了好久。

12.6 滤镜渲染:从简单到复杂

有了纹理,滤镜就是写 Fragment Shader 的事了。我习惯把滤镜分为三类:

类型 原理 示例
颜色滤镜 修改像素的 RGB 值 灰度、怀旧、冷色调
卷积滤镜 用卷积核处理像素邻域 模糊、锐化、边缘检测
混合滤镜 多纹理叠加或混合 美颜、贴纸、双重曝光

最简单的灰度滤镜,Fragment Shader 长这样:

#version 300 es
precision mediump float;
uniform sampler2D uTexture;
in vec2 vTexCoord;
out vec4 fragColor;

void main() {
    vec4 color = texture(uTexture, vTexCoord);
    float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
    fragColor = vec4(vec3(gray), color.a);
}

你看,核心就是一行 dot 运算。你想想看,从 Camera2 拿到实时画面,到 OpenGL 纹理,再到 Shader 里做颜色变换,整个过程行云流水。

12.7 滤镜链:多个滤镜叠加

实际项目中,很少只用一个滤镜。用户可能想先调亮度,再加个美颜,最后加个边框。这就涉及到滤镜链的概念。

我习惯的做法是:用多个 FBO(帧缓冲对象)串联起来。每个 FBO 对应一个滤镜,上一个的输出纹理作为下一个的输入。

原始纹理 滤镜1 FBO1 滤镜2 FBO2 滤镜3 FBO3 输出

代码实现上,每个滤镜就是一个渲染 Pass:

// 滤镜链渲染
int currentTexture = originalTextureId;
for (Filter filter : filterChain) {
    // 绑定当前滤镜的 FBO
    GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, filter.getFboId());
    // 用当前纹理作为输入,渲染到 FBO
    filter.render(currentTexture);
    // 下一个滤镜的输入就是当前 FBO 的纹理
    currentTexture = filter.getOutputTextureId();
}
// 最后把结果渲染到屏幕
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);
renderToScreen(currentTexture);

注意:滤镜链越长,GPU 开销越大。在低端机型上,3 个滤镜以上就可能掉帧。我建议在性能敏感的场景下,把多个滤镜合并到一个 Shader 里,减少 Pass 数量。

12.8 实战中的几个坑

最后分享几个我实际项目中踩过的坑,希望能帮你少走弯路:

  • 纹理大小不匹配:Camera2 输出的分辨率可能跟 OpenGL 视口不一致。记得用 getTransformMatrix() 获取变换矩阵,应用到顶点坐标上。
  • EGL 上下文丢失:App 切到后台再回来,EGL 上下文可能失效。需要重建纹理、SurfaceTexture 和 FBO。
  • 内存泄漏:SurfaceTexture 用完后记得 release(),否则 Camera2 会话无法正常关闭。
  • 线程安全:SurfaceTexture 的方法不是线程安全的。所有操作都放在 OpenGL 线程里做。

Camera2 与 OpenGL 的结合,说难不难,说简单也不简单。核心就是理解 SurfaceTexture 这个桥梁的作用,以及纹理更新的时机。一旦你掌握了这套流程,实时滤镜、美颜、特效、视频录制,统统不在话下。


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