18. OpenGL ES相机预览:EGL环境搭建、SurfaceTexture与OpenGL绑定、绘制纹理三角形

好,咱们今天聊点硬核的。

前面几章我们一直在用 Camera2 的 API 直接拿到预览画面,但说实话,那只是 Android 帮你封装好的“傻瓜模式”。如果你想做滤镜、美颜、AR 特效,就必须自己接管渲染管线。而这一切的起点,就是 OpenGL ES。

我个人习惯把 OpenGL ES 比作一个“画板”,CPU 负责算数据,GPU 负责画出来。但 Android 的相机预览数据不是直接丢给 GPU 的——中间有个关键的桥梁,叫 SurfaceTexture

18.1 为什么需要 EGL?

你可能会问:我直接用 GLSurfaceView 不就行了?

嗯,GLSurfaceView 确实帮你搭好了 EGL 环境,但它有个限制——它必须依附于 Activity 的 View 层级。如果你想把相机帧渲染到一个自定义的 Surface 上,或者做后台处理,GLSurfaceView 就不好使了。

这时候,你需要手动搭建 EGL 环境。

EGL 是什么?说白了,它是 OpenGL ES 和本地窗口系统之间的“翻译官”。没有它,OpenGL 不知道往哪画。

核心概念:EGL 负责创建 OpenGL 的上下文(Context)和绘制表面(Surface)。一个线程要使用 OpenGL,必须先绑定一个 EGL 上下文。

18.2 手动搭建 EGL 环境

我在项目中遇到过好几次这样的场景:需要在子线程里用 OpenGL 处理相机帧,然后输出到 MediaCodec 编码成视频。这时候 GLSurfaceView 帮不上忙,只能自己搭 EGL。

搭建步骤其实就四步:

  1. 获取 EGL 显示设备(display)
  2. 初始化 EGL
  3. 配置 EGL 属性,选择配置
  4. 创建 EGL 上下文和 Surface

来看代码:

// 1. 获取默认 display
EGLDisplay display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY);
if (display == EGL14.EGL_NO_DISPLAY) {
    throw new RuntimeException("eglGetDisplay failed");
}

// 2. 初始化
int[] version = new int[2];
if (!EGL14.eglInitialize(display, version, 0, version, 1)) {
    throw new RuntimeException("eglInitialize failed");
}

// 3. 配置属性
int[] attribList = {
    EGL14.EGL_RED_SIZE, 8,
    EGL14.EGL_GREEN_SIZE, 8,
    EGL14.EGL_BLUE_SIZE, 8,
    EGL14.EGL_ALPHA_SIZE, 8,
    EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT,
    EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT,
    EGL14.EGL_NONE
};
EGLConfig[] configs = new EGLConfig[1];
int[] numConfigs = new int[1];
EGL14.eglChooseConfig(display, attribList, 0, configs, 0, 1, numConfigs, 0);

// 4. 创建上下文和 Surface
int[] contextAttribs = {
    EGL14.EGL_CONTEXT_CLIENT_VERSION, 2,
    EGL14.EGL_NONE
};
EGLContext context = EGL14.eglCreateContext(display, configs[0], EGL14.EGL_NO_CONTEXT, contextAttribs, 0);

// 假设 surface 是一个 Surface 对象(来自 SurfaceView 或 TextureView)
EGLSurface eglSurface = EGL14.eglCreateWindowSurface(display, configs[0], surface, null, 0);

// 绑定
EGL14.eglMakeCurrent(display, eglSurface, eglSurface, context);

小提示:记得在销毁时按顺序释放:先 eglMakeCurrent 解绑,再 eglDestroySurface,最后 eglDestroyContext 和 eglTerminate。顺序错了容易崩溃。

18.3 SurfaceTexture:相机到 OpenGL 的桥梁

EGL 环境搭好了,但相机数据怎么进来?

Android 相机输出的数据是 YUV 格式,OpenGL 不能直接吃。这时候 SurfaceTexture 登场了。

SurfaceTexture 是什么?它接收相机输出的帧数据,然后转换成 OpenGL 纹理。你想想看,它就像一个“数据中转站”:一头连着 Camera2 的 CaptureSession,另一头连着 OpenGL 的纹理 ID。

用法很简单:

// 创建纹理 ID
int[] textures = new int[1];
GLES20.glGenTextures(1, textures, 0);
int textureId = textures[0];

// 创建 SurfaceTexture,绑定到纹理
SurfaceTexture surfaceTexture = new SurfaceTexture(textureId);

// 设置回调,当新帧到来时通知
surfaceTexture.setOnFrameAvailableListener(new SurfaceTexture.OnFrameAvailableListener() {
    @Override
    public void onFrameAvailable(SurfaceTexture surfaceTexture) {
        // 标记有新帧,在渲染循环中更新纹理
        frameAvailable = true;
    }
});

// 把 SurfaceTexture 的 Surface 传给 Camera2
Surface surface = new Surface(surfaceTexture);
captureSession.setRepeatingRequest(request, captureCallback, handler);

这里有个坑,我曾经踩过:SurfaceTexture 必须在 OpenGL 线程中创建。因为纹理 ID 是和 EGL 上下文绑定的,你在主线程创建纹理,子线程用不了。

18.4 更新纹理与绘制三角形

当相机帧到来时,我们需要在渲染循环里调用 surfaceTexture.updateTexImage() 来更新纹理内容。

然后,就可以用这个纹理来绘制了。但为了讲清楚流程,我们先画一个简单的纹理三角形。

绘制纹理三角形的步骤:

  1. 编写顶点着色器和片段着色器
  2. 创建顶点数据和纹理坐标数据
  3. 上传纹理
  4. 绘制

着色器代码:

// 顶点着色器
private static final String VERTEX_SHADER =
    "attribute vec4 aPosition;\n" +
    "attribute vec2 aTexCoord;\n" +
    "varying vec2 vTexCoord;\n" +
    "void main() {\n" +
    "    gl_Position = aPosition;\n" +
    "    vTexCoord = aTexCoord;\n" +
    "}";

// 片段着色器
private static final String FRAGMENT_SHADER =
    "precision mediump float;\n" +
    "uniform sampler2D uTexture;\n" +
    "varying vec2 vTexCoord;\n" +
    "void main() {\n" +
    "    gl_FragColor = texture2D(uTexture, vTexCoord);\n" +
    "}";

顶点数据:

// 三角形三个顶点(归一化坐标)
float[] vertexData = {
    -0.5f, -0.5f,  // 左下
     0.5f, -0.5f,  // 右下
     0.0f,  0.5f   // 顶部
};

// 对应的纹理坐标
float[] texCoordData = {
    0.0f, 1.0f,  // 左下
    1.0f, 1.0f,  // 右下
    0.5f, 0.0f   // 顶部
};

绘制循环:

// 在渲染循环中
if (frameAvailable) {
    surfaceTexture.updateTexImage();
    frameAvailable = false;
}

// 清屏
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);

// 绑定纹理
GLES20.glActiveTexture(GLES20.GL_TEXTURE0);
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);

// 绘制三角形
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3);

// 交换缓冲区
EGL14.eglSwapBuffers(display, eglSurface);

注意:纹理坐标的 Y 轴方向和屏幕坐标是相反的。相机输出的图像是倒的,如果你发现画面上下颠倒,记得在纹理坐标上做翻转,或者用矩阵修正。

18.5 核心流程总结

来,我把整个流程画成一张图,方便你理解:

OpenGL ES 相机预览核心流程 Camera2 SurfaceTexture updateTexImage() OpenGL 纹理 EGL 环境 EGLDisplay EGLContext EGLSurface eglSwapBuffers 相机帧 → SurfaceTexture → OpenGL纹理 → EGL交换 → 屏幕显示

说白了,整个流程就是:相机把数据塞给 SurfaceTexture,SurfaceTexture 转成纹理,OpenGL 拿到纹理后渲染,最后通过 EGL 把结果送到屏幕上。

嗯,这里要注意一点:SurfaceTexture 的 updateTexImage() 必须在 OpenGL 线程中调用,而且每次新帧到来只能调用一次。如果你在回调里直接调用,可能会造成死锁。我一般用一个标志位,在渲染循环里统一处理。

18.6 避坑指南

最后,分享几个我踩过的坑:

  • 纹理 ID 重复使用:如果你在多个 SurfaceTexture 之间切换,记得 glDeleteTextures 释放旧的纹理 ID,否则会内存泄漏。
  • EGL 上下文切换开销:频繁 eglMakeCurrent 很费性能。尽量让一个线程持有一个上下文,不要来回切。
  • 纹理坐标翻转:相机输出的 Y 轴方向和 OpenGL 是反的。如果你发现画面上下颠倒,在纹理坐标的 y 轴上做 1.0 - y 即可。
  • SurfaceTexture 的 release:不再使用时一定要调用 release(),否则相机 Session 可能无法正常关闭。

我曾经在一个项目里,因为忘记释放 SurfaceTexture,导致相机打开第二次时直接黑屏。查了半天才发现是纹理资源没释放干净。

好了,这一章的内容就到这。EGL 环境搭建 + SurfaceTexture 绑定 + 纹理绘制,这三步是 OpenGL 相机预览的基石。后面我们会在这个基础上做滤镜、美颜、甚至 AR 特效——但前提是,你得先把今天的内容吃透。


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