ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Android OpenGL ES:将GL_TEXTURE_2D纹理渲染到ImageReader Surface的完整指南

Android OpenGL ES:将GL_TEXTURE_2D纹理渲染到ImageReader Surface的完整指南 简介针对 Android 平台上将 OpenGLES3 纹理 ID 渲染到 ImageReader Surface 的需求这份资源以完整可运行的 Android 工程形式提供了从纹理加载、EGLDisplay 虚拟屏幕绑定、eglSwapBuffers 缓冲交换到 ImageReader 回调读取数据并生成 Bitmap 的端到端参考实现。源码按功能拆分结构清晰适合需要对接相机/视频帧数据或做离屏渲染的 Android 中高级开发者参考。包含 89 个文件压缩包仅 879KB。工程采用 Android Gradle 标准结构java 文件承载渲染线程与回调核心逻辑xml 完成界面布局及清单配置gradle 与 properties 管理依赖和构建参数webp 等用于界面辅助展示bin、lock 等则属于工程编译缓存。代码中对 EGL 环境搭建、纹理加载、Surface 数据通路和 onImageAvailable 取帧等关键节点均有直接体现便于对照项目进行流程梳理、移植或性能调优。目前已有 731 人学习/下载适合正在钻研 Android 图形管线、希望打通 OpenGLES 与 Surface 之间数据链路的开发者。1. 为什么要把 GL_TEXTURE_2D 纹理 ID 渲染到 ImageReader 的 Surface 上在 Android 图形链路里纹理 ID 往往是离屏渲染链尾部的产物FBO 完成特效合成、第三方 SDK 输出一帧经过 GL_TEXTURE_2D 的纹理下一步要么送编码器要么给系统拍下来。但纹理 ID 只是 GPU 里的一个“名字”MediaCodec、ImageReader 不认识它它们认识的是 Surface——或者说得更准确一点认识一块 BufferQueue 的生产端。把 GL_TEXTURE_2D 纹理 ID 画到 ImageReader 提供的 Surface 上本质是在 GPU 里把一张纹理完整搬一次家完成一次低成本的颜色缓冲交换而不是把图像数据读回 CPU 再写出去。这个需求常见于视频编辑、老照片修复、平台美颜抽帧和低延迟串流。它比 glReadPixels 高效得多比使用 SurfaceTexture 多一层可读性也比直接向 MediaCodec 编码器 Surface 输出更能兼容后续的 CPU 读帧逻辑。适合两类人一类是想把 OpenGL ES 3.0 离屏渲染结果交给系统编码、录屏、拍照的工程师另一类是手头有一个来自第三方库的 GL_TEXTURE_2D 纹理 ID不知道如何不碰像素就转型成 Android 可消费帧的人。这篇文章按我的惯用方案把 EGL 环境、ImageReader Surface 初始化、渲染代码、同步栅栏和编码器接入全部拆开。2. 用 EGL 把 ImageReader 的 Surface 接进 OpenGL ES 3.0先解决“画到哪”几乎所有 Android 开发者看到 ImageReader 的第一反应都是调onImageAvailable后拿Image对象逐一读 Plane。这个思路只覆盖了消费端忽略了 ImageReader 另一个更重要的身份它的getSurface()返回值是一个标准 Surface而 Surface 背后是 BufferQueue 的生产端。换句话说GPU 可以把它当作一块可绘制的缓冲区域只要 EGL 愿意认领这块 Surface。2.1 ImageReader 的 Surface 不是 View渲染路径完全不同TextureView、SurfaceView 的 Surface 与 ImageReader 的 Surface 在底层都关联 BufferQueue但在 API 表现上差异很大。SurfaceView 上可以用 OpenGL 绘制TextureView 本身是 View 层级里的一个图层而 ImageReader 的 Surface 只能作为 ImageReader 的消费缓冲入口。它没有窗口句柄也不能被 attach 到窗口系统唯一的接入方式就是通过 EGL 的eglCreateWindowSurface。很多人在这一步选错 API把 ImageReader 的 Surface 当 SurfaceTexture 传进setPreviewTexture或者尝试Canvas.lockCanvas后手动编解码都绕了远路。常规做法是创建 ImageReader取出其 Surface然后在 EGL 层创建 EGLSurface之后所有 GL 绘制命令都会落到这块 Surface 上eglSwapBuffers之后 ImageReader 就能异步收到一张全新 Image。下面是四种常见 Surface 消费者的对比对象可被 GL 绘制能否 CPU 读像素典型用途SurfaceView 的 Surface可以需要额外处理游戏、视频播放TextureView 的 SurfaceTexture可以需要额外处理相机预览、UI 合成MediaCodec 的 input Surface可以不可以直接读硬编码输入ImageReader 的 Surface可以通过 EGL 接入可以且是设计目标拍帧、读纹理、YUV 转换2.2 EGL Surface 与 ImageReader 的像素格式必须匹配EGL 是 OpenGL ES 和窗口系统之间的桥梁。把 ImageReader 的 Surface 接进来核心是eglGetDisplay、eglInitialize、eglChooseConfig、eglCreateWindowSurface这条链路。其中最容易出问题的是 EGLConfig 的选择ImageReader 的缓冲格式由构造时的 pixelFormat 决定而 EGLWindowSurface 创建时不能直接指定格式它必须从 EGLConfig 中推导。如果 EGLConfig 的 RGBA 位数与 ImageReader 不一致轻则色彩偏移重则eglCreateWindowSurface直接返回EGL_BAD_MATCH。以下是我在实际项目里稳定使用的 EGL 初始化片段EGLDisplay display EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); int[] version new int[2]; EGL14.eglInitialize(display, version, 0, version, 1); int[] configAttrs { EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT | 0x0040, // 0x0040 对应 EGL_OPENGL_ES3_BIT EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8, EGL14.EGL_BLUE_SIZE, 8, EGL14.EGL_ALPHA_SIZE, 8, EGL14.EGL_NONE }; EGLConfig[] configs new EGLConfig[1]; int[] numConfigs new int[1]; EGL14.eglChooseConfig(display, configAttrs, 0, configs, 0, 1, numConfigs, 0); int[] surfaceAttrs { EGL14.EGL_NONE }; EGLSurface eglSurface EGL14.eglCreateWindowSurface(display, configs[0], imageReaderSurface, surfaceAttrs, 0);这段代码里第二行的0x0040很关键。Android 的 EGL 头文件对EGL_OPENGL_ES3_BIT_KHR的定义是 0x0040而EGL14.EGL_OPENGL_ES2_BIT的值是 0x0004两者并不相同。如果只写入 ES2 bit某些机型上依然能创建成功但后续eglCreateContext请求的 OpenGL ES 3.0 context 与 EGLConfig 的 renderable type 不匹配会得到EGL_BAD_MATCH或EGL_BAD_CONFIG。2.3 共享上下文纹理 ID 在新 EGLContext 里“仍然有效”EGLSurface 创建完成后下一步是创建 EGLContext。如果你已经有另一个线程在渲染纹理比如相机模块或第三方滤镜库在某个 GL 线程上生成了纹理 ID那么新的渲染线程必须创建共享上下文。EGLContext 默认拥有独立的资源命名空间纹理 ID 只是一个小整数在不同 context 里如果不同步它在 GL 侧会被当作“不存在的对象”即使调用glBindTexture也不会报错绘制结果只会是黑色。创建共享上下文的方法是先拿到当前线程的活跃 contextEGL14.eglGetCurrentContext()然后把它传给eglCreateContext的第二个参数。这样两个 context 共享纹理、FBO 等对象纹理 ID 才能跨线程使用。但如果原纹理所在线程根本没有 EGLContext而是通过其他方式生成的那问题就不一样了——那种情况通常要用扩展接口导出内存句柄这里不展开。3. 渲染 GL_TEXTURE_2D 到 ImageReader Surface 的完整实现有了 EGL Surface 和共享上下文渲染核心就只剩三件事创建 Shader 程序、绑定纹理、提交绘制。这一章给出可以直接抄进工程的 Java 实现用的是 OpenGL ES 3.0 API没有引入任何第三方依赖Android Studio 里新建工程即可运行。3.1 初始化 EGL 环境与创建 EGLSurface 的代码把上一章的逻辑收拢成一个初始化方法注意imageReader必须提前创建并且宽高要与纹理一致public void init(ImageReader imageReader, EGLContext sharedContext, int textureId) { mImageReader imageReader; mTextureId textureId; mWidth imageReader.getWidth(); mHeight imageReader.getHeight(); mDisplay EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); int[] version new int[2]; EGL14.eglInitialize(mDisplay, version, 0, version, 1); int[] configAttrs { EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT | 0x0040, EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8, EGL14.EGL_BLUE_SIZE, 8, EGL14.EGL_ALPHA_SIZE, 8, EGL14.EGL_NONE }; EGLConfig[] configs new EGLConfig[1]; int[] numConfigs new int[1]; EGL14.eglChooseConfig(mDisplay, configAttrs, 0, configs, 0, 1, numConfigs, 0); mSurface EGL14.eglCreateWindowSurface(mDisplay, configs[0], imageReader.getSurface(), new int[]{EGL14.EGL_NONE}, 0); int[] contextAttrs { EGL14.EGL_CONTEXT_CLIENT_VERSION, 3, EGL14.EGL_NONE }; mContext EGL14.eglCreateContext(mDisplay, configs[0], sharedContext, contextAttrs, 0); EGL14.eglMakeCurrent(mDisplay, mSurface, mSurface, mContext); setupShaders(); }参数说明sharedContext是纹理来源线程的 EGLContext如果传入的 textureId 就在当前线程创建这里可以直接传EGL14.EGL_NO_CONTEXT。EGL_CONTEXT_CLIENT_VERSION, 3决定创建的是 OpenGL ES 3.0 context注意 EGLConfig 里必须带上 ES3 bit否则这里报错。eglMakeCurrent的作用是把 EGLSurface 绑定到当前线程的渲染目标之后所有 GLES3 指令都作用于这块 ImageReader Surface。3.2 Shader 与绘制命令如何用三角形覆盖纹理ImageReader 的 Surface 是一块矩形缓冲渲染时最常见的方式是画两个三角形拼成一个全屏四边形。顶点着色器负责接收顶点坐标和纹理坐标片段着色器负责从 GL_TEXTURE_2D 上采样颜色。顶点着色器#version 300 es layout(location 0) in vec4 aPosition; layout(location 1) in vec2 aTexCoord; out vec2 vTexCoord; void main() { gl_Position aPosition; vTexCoord aTexCoord; }片段着色器#version 300 es precision mediump float; uniform sampler2D uTexture; in vec2 vTexCoord; out vec4 fragColor; void main() { fragColor texture(uTexture, vTexCoord); }这个着色器没有做任何颜色变换和矩阵运算保留纹理原始颜色。sampler2D默认绑定纹理单元 0所以绘制前不需要显式glActiveTexture只要把纹理 ID bind 到GL_TEXTURE_2D上即可。如果将来要接入 OES 外部纹理需要将采样器类型换成samplerExternalOES同时把纹理目标改成GL_TEXTURE_EXTERNAL_OES与本文标题锁定的 GL_TEXTURE_2D 场景不是同一套配置。3.3 draw 帧的调用时机与纹理参数顶点数据和绘制代码private void drawFrame() { EGL14.eglMakeCurrent(mDisplay, mSurface, mSurface, mContext); GLES30.glViewport(0, 0, mWidth, mHeight); GLES30.glUseProgram(mProgram); GLES30.glBindBuffer(GLES30.GL_ARRAY_BUFFER, mVbo); GLES30.glEnableVertexAttribArray(0); GLES30.glVertexAttribPointer(0, 3, GLES30.GL_FLOAT, false, 5 * 4, 0); GLES30.glEnableVertexAttribArray(1); GLES30.glVertexAttribPointer(1, 2, GLES30.GL_FLOAT, false, 5 * 4, 3 * 4); // 关键绑定纹理 ID 到 GL_TEXTURE_2D 目标 GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, mTextureId); GLES30.glDrawArrays(GLES30.GL_TRIANGLE_STRIP, 0, 4); EGL14.eglSwapBuffers(mDisplay, mSurface); }glVertexAttribPointer(1, 2, ..., 5 * 4, 3 * 4)表示顶点数据每 5 个 float 为一组前 3 个是位置后 2 个是纹理坐标stride 为 20 字节。绘制的 4 个顶点可以用(-1,-1,0,0,0)到(1,1, ..., 1,1)的三角形带覆盖全屏。纹理绑定后建议检查纹理对象的过滤参数。外部库生成的纹理 ID 不一定设置了GL_TEXTURE_MIN_FILTER如果默认值是GL_NEAREST_MIPMAP_LINEAR而纹理没有生成 mipmap画面会出现模糊接缝或黑边。可在drawFrame里每次绑定时执行一次glTexParameteriGLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_MIN_FILTER, GLES30.GL_LINEAR); GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_MAG_FILTER, GLES30.GL_LINEAR); GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_WRAP_S, GLES30.GL_CLAMP_TO_EDGE); GLES30.glTexParameteri(GLES30.GL_TEXTURE_2D, GLES30.GL_TEXTURE_WRAP_T, GLES30.GL_CLAMP_TO_EDGE);GL_CLAMP_TO_EDGE可以避免纹理边缘采样到透明或黑色区域。如果纹理 ID 来源是最新一帧相机 YUV 转成的 GL_TEXTURE_2D换帧时 GPU 和 CPU 之间可能还有未完成写入需要在 draw 之前插入glFinish()或使用同步栅栏否则第一帧可能出现纹素错乱。4. 共享上下文、同步与格式最常出问题的 3 个位置EGL 初始化完成后渲染逻辑本身很短真正消耗时间的是排错。这一章只讨论最常导致黑屏、花屏、绿屏的三个因素上下文共享失效、GPU 绘制与 ImageReader 消费之间的时序、像素格式不匹配。4.1 纹理 ID 不是“全局身份证”必须用共享上下文许多人拿到外部传入的 textureId 后既不询问它的 EGLContext也不做共享直接在新线程里glBindTexture后绘制得到的结果是黑屏但不报错。原因是 GL 纹理 ID 在 context 的资源表里只是一个索引新 context 里没有这张表的注册信息。共享上下文的创建方法在 2.3 节已经说明这里补充一个常见误用只共享 EGLDisplay却不共享 EGLContext这种代码在开发机上偶尔能跑因为某些驱动厂商把资源表做成了全局的但压测和真机更换设备后立刻崩溃。推荐的做法是在纹理生产方初始化时记录一个全局变量sSharedContext EGL14.eglGetCurrentContext();然后在渲染线程 init 时把它作为创建参数传进去。如果纹理生产方没有 EGLContext直接传EGL_NO_CONTEXT。注意共享 context 只共享对象不共享 GL 状态机所以纹理参数、Shader、VBO 都要在新 context 里重新设置。4.2 EGL 渲染先行ImageReader 异步消费同步在哪做ImageReader 的 Image 是异步到达的onImageAvailable触发时并不代表 GPU 绘制已经完成。eglSwapBuffers只表示缓冲被送交 BufferQueue不保证 GPU 已结束渲染。在部分设备上ImageReader 拿到帧时纹理内容还处于可写状态后面消费线程读取时会出现半帧或内容撕裂。如果追求稳妥最直接的做法是在eglSwapBuffers后插入 GPU 等待EGL14.eglSwapBuffers(mDisplay, mSurface); // 等待 GPU 完成所有绘制指令确保 ImageReader 能拿到已完成的帧 GLES30.glFinish();glFinish会让 CPU 阻塞直到 buffer 可复用。对于 30fps 的抽帧需求影响不大但如果要跑 60fps建议换成 fenceEGLSyncKHR fence EGLExt.eglCreateSyncKHR(mDisplay, EGLExt.EGL_SYNC_NATIVE_FENCE_ANDROID, new int[]{ EGLExt.EGL_SYNC_NATIVE_FENCE_ANDROID, 0, EGL14.EGL_NONE }, 0); // 从 fence 里取出 native fence fd传给消费端等待完成后 close这是 Android 推荐的低延迟方案把 fence fd 一并传给消费者而不是让 CPU 在 GPU 完成后才继续跑。如果当前只做总结输出glFinish简单不易错。4.3 像素格式与 Size 对齐ImageReader 不是随便创建的ImageReader 的构造参数pixelFormat必须与 EGLConfig 的 RGBA 位数一致否则画面颜色会偏移或者完全失真。RGB_565、RGBA_8888、YUV_420_888 在 BufferQueue 里是不同格式EGL 无法对一个 YUV 格式的 Surface 直接执行 RGBA 的渲染操作。标题锁定的场景是 OpenGL ES 3.0 输出所以 ImageReader 通常这样创建mImageReader ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2);注意最后那个maxImages参数。如果设成 1渲染频率高时消费者来不及 closeImageReader 会停止向 BufferQueue 请求新缓冲eglSwapBuffers会卡死设成 4 又会成倍占用内存。2 是绝大多数抽帧和编码场景下的默认值。宽度和高度也需要对齐。给 ImageReader 传奇数尺寸部分设备在eglCreateWindowSurface阶段就失败部分在ImageReader.getPlanes()阶段因为 stride 和 width 不一致返回错误的 Buffer 布局。常规做法是先对尺寸做对齐再创建int alignedWidth (width 15) / 16 * 16; int alignedHeight (height 15) / 16 * 16;纹理渲染到对齐后的 Surface 上如果 UVC 相机原始尺寸是 640x480对齐后依然是这两个数字因为 16 的倍数对齐不会改变这两个值。5. 接入 MediaCodec 编码器与 30fps 帧率控制一个可收尾的技巧渲染到 ImageReader 的最终目的大多离不开编码。一个更高效的做法是让 OpenGL ES 3.0 直接绘制到 MediaCodec 编码器的输入 Surface省掉 ImageReader 这个中间层。但有时业务需要同时保留 CPU 读帧能力那就必须双 Surface 并行。5.1 如果你要编码别让 ImageReader 成为中间跳板常见错误写法是先渲染到 ImageReader再在onImageAvailable里把Image的 buffer 拷贝出来通过MediaCodec.queueInputBuffer喂给编码器。这不叫 GPU 渲染等于把 GPU 画的帧又回到了 CPU再交给编码器性能损耗巨大。如果不需要 CPU 读帧直接创建编码器输入 SurfacemEncoder MediaCodec.createEncoderByType(video/avc); MediaFormat format MediaFormat.createVideoFormat(video/avc, alignedWidth, alignedHeight); format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); format.setInteger(MediaFormat.KEY_BIT_RATE, 4_000_000); format.setInteger(MediaFormat.KEY_FRAME_RATE, 30); format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1); mEncoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); Surface encoderSurface mEncoder.createInputSurface(); mEncoder.start();然后把encoderSurface传给第 3 章的init方法ImageReader 那条路径留作旁路抽帧。5.2 两个 EGLSurface 交替渲染同时保留读像素能力为了同时满足编码和读帧可以创建两个 EGLSurface一个绑定 ImageReader一个绑定编码器输入 Surface。每次drawFrame时按当前消费者选择目标EGL14.eglMakeCurrent(mDisplay, chooseSurface(), chooseSurface(), mContext); GLES30.glFinish(); EGL14.eglSwapBuffers(mDisplay, chooseSurface());chooseSurface()根据当前是否处于检测阶段返回对应 EGLSurface。这样编码链路的每一帧都是 GPU 直接写入不经过 CPUImageReader 路径只在真正需要抽帧的瞬间才切换过去。帧率控制可以用Choreographer在 VSYNC 信号上回调或者用循环控制long startTime System.nanoTime(); long frameInterval 33_000_000L; // 30fps单位纳秒 while (running) { drawFrame(); long elapsed System.nanoTime() - startTime; if (elapsed frameInterval) { SystemClock.sleep((frameInterval - elapsed) / 1_000_000L); } startTime System.nanoTime(); }SystemClock.sleep比Thread.sleep更精确它不受 Java 层时间调整影响适合在 GL 线程里做节奏控制。5.3 用 adb 验证 ImageReader 是否真的在刷新接入完成后如果无法确定 ImageReader 是否收到帧可以用 adb 查看 SurfaceFlinger 的图层状态。先列出所有 Surface 名称找到 ImageReader 对应的项adb shell dumpsys SurfaceFlinger --list对比渲染前后同一 Surface 的z-order或visible region变化可以确认是否提交了缓冲。但要注意一点ImageReader 表面在eglSwapBuffers后不会永远可见如果消费者没有及时 close 图像Surface 图层可能被 SurfaceFlinger 判定为无内容而在--list中消失。遇到这种情况优先检查onImageAvailable是否执行以及Image.close()是否及时调用。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表