尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Android OpenGL ES进阶:自建EGL渲染线程替代GLSurfaceView
在 Android 上用 OpenGL ES 渲染图像很多人第一反应就是GLSurfaceView。我刚开始接触图形开发时也一样new 一个MyRenderer在onDrawFrame里画个三角形清屏、画图一套流程非常顺手。但后来做自定义滤镜、离屏截图、视频帧回贴甚至想在同一块 EGL 环境里开多个上下文共享纹理时GLSurfaceView那套“约定”反而成了瓶颈。这篇文章想聊的就是当你决定不依赖GLSurfaceView时从SurfaceView EGL底层开始怎么把一张图像真正摄入屏幕。适合已经会用 GLSurfaceView、但想搞懂底层机制的开发者也适合想把渲染控制权完全拿回手里的朋友。先说清楚一个概念GLSurfaceView不是 OpenGL ES 的一部分它只是 Android 系统提供的一个封装类。真正负责兼容连接 OpenGL ES 和系统窗口的是EGL。把封装剥掉整个渲染链条其实是一个原生 Surface 一个 EGL 上下文 一个持续绘制线程。接下来我就按这个链条一步一步拆开讲。1. 摆脱 GLSurfaceView 之前先看清它到底替你干了什么很多人觉得自己写了setRenderer()就是在写 OpenGL 了其实大部分工作都被GLSurfaceView悄悄做了。理解这点是决定要不要自己手写的前提。1.1 GLSurfaceView 的“三件套”线程、EGL、回调封装GLSurfaceView继承自SurfaceView然后在 Java 层把三件事组合了起来监听SurfaceHolder获取一块可以绘制的原生Surface。自动创建一个名为GLThread的后台线程这个线程内部自己管理 EGL 上下文和 EGL Surface 的生命周期。把用户需要执行的绘制过程抽象成Renderer接口在后台线程回调onSurfaceCreated、onSurfaceChanged、onDrawFrame。也就是说开发者提供的Renderer只负责“画什么”而“在哪里画、什么时候画、EGL 怎么初始化、surface 销毁后怎么处理”全被封装掉了。这套封装对大多数业务场景是高效的尤其是很多朋友只想快速显示一张纹理或一个 3D 场景时完全够用。但封装也意味着限制你不能控制 EGL 配置选中的细节不能在一个线程里同时管理多个 EGL Context不能决定 EGL Surface 创建和销毁的时机更没法在 surface 还没创建好的情况下提前初始化上下文。这些能力恰恰在做复杂图像处理时非常关键。1.2 自己动手能换来哪些自由又得背哪些锅自己搭建渲染底座换来最明显的几个自由度渲染循环完全由自己的线程控制可以按业务需要休眠、唤醒、按帧间隔执行而不是被GLThread固定节奏拖着跑。可以在不创建窗口 Surface 的情况下用 FBO 或 PBUFFER 离线渲染再把结果传给其他模块。能同时创建多个 EGL Context并共享纹理、字节缓冲等资源这在滤镜链、混合渲染、分屏处理中很常见。可以自行决定 surface 变化时是重建 EGL Surface 还是只更新视口能明显降低画面闪烁。代价也很直接所有 EGL 初始化、错误码检查、线程生命周期、资源释放都要自己处理。踩坑的典型场景包括忘记在 UI 线程回调里保存宽高、渲染线程已经跑起来了 Surface 才创建、surfaceDestroyed 时还在交换缓冲、所有 GL 调用没有保证在同一个线程执行。锅虽然多但只要理清关键流程这些问题都能用一套清晰的状态机解决。2. 手动渲染的图像必经之路EGL 环境搭建与关键对象如果从零开始OpenGL ES 只是“画图指令”真正和系统窗口、显示设备接洽的是 EGL。这一章把 EGL 的核心机制讲透后面写代码才不会一头雾水。2.1 EGL 到底是个什么胶水层EGL 的全称是 Embedded-System Graphics Library它管理 OpenGL ES 和原生窗口系统之间的接口。OpenGL ES 本身不知道“窗口”“显示”“垂直同步”这些概念它只负责在一个抽象的绘图上下文里执行着色器和几何绘制而 EGL 负责提供绘图表面、管理上下文切换、控制双缓冲交换。你可以这样理解OpenGL ES 是一个画家EGL 是剧院里的舞台经理。画家只需要知道“在画布上画”而舞台经理负责准备好画布、调整灯光、决定什么时候把画好的新一页翻给观众看。GLSurfaceView只是把舞台经理预先配置成了一个固定方案现在我们自己当舞台经理。2.2 四个关键对象Display、Config、Context、Surface手动初始化 EGL 时四个对象缺一不可对象含义生活化类比Display代表到显示系统的一条连接通道一般取默认显示设备剧院的入口和门禁Config描述了一个绘图表面的像素格式、颜色位数、缓冲类型等配置画布的尺寸、材质、涂层规格Context保存 OpenGL ES 的全部状态包括着色器、纹理绑定、顶点缓冲等画家的整套画笔和调色盘Surface实际可绘制的缓冲区域可以是连接到屏幕的窗口也可以是离屏内存真正铺在画架上的那幅画布这四个对象的关系是Display上创建Context和Surface然后用eglMakeCurrent把某一个Context和某一个Surface绑定到当前线程。绑定之后当前线程执行的 GL 指令就会渲染到那个 Surface 上。这里有一个必须记住的原则OpenGL ES 的上下文和线程是绑定关系。一个 EGL Context 同时只能被一个线程设置为当前切换线程前必须先eglMakeCurrent到另一个线程或释放当前绑定。2.3 最小 EGL 初始化流程举例下面这段代码是手动渲染时的最小骨架我用 Android 的EGL14接口来写API 级别 17 以上都适用基本上你手头所有真机都支持。private EGLDisplay mEglDisplay; private EGLContext mEglContext; private EGLSurface mEglSurface; private boolean initEgl(Surface surface) { int[] version new int[2]; // 1. 获取 Display 并初始化 mEglDisplay EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); if (mEglDisplay EGL14.EGL_NO_DISPLAY) { return false; } if (!EGL14.eglInitialize(mEglDisplay, version, 0, version, 1)) { return false; } // 2. 选择 Config8bit RGBA ES2 图形接口 int[] configAttribs { 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]; if (!EGL14.eglChooseConfig(mEglDisplay, configAttribs, 0, configs, 0, 1, numConfigs, 0) || numConfigs[0] 0) { return false; } EGLConfig config configs[0]; // 3. 创建上下文指定使用 OpenGL ES 2.0 int[] contextAttribs { EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE }; mEglContext EGL14.eglCreateContext( mEglDisplay, config, EGL14.EGL_NO_CONTEXT, contextAttribs, 0); if (mEglContext EGL14.EGL_NO_CONTEXT) { return false; } // 4. 创建窗口 Surface这里传入的是 SurfaceView 提供的原生 Surface mEglSurface EGL14.eglCreateWindowSurface( mEglDisplay, config, surface, new int[] { EGL14.EGL_NONE }, 0); if (mEglSurface EGL14.EGL_NO_SURFACE) { return false; } // 5. 绑定当前线程的上下文与表面 if (!EGL14.eglMakeCurrent(mEglDisplay, mEglSurface, mEglSurface, mEglContext)) { return false; } return true; }这段代码里的configAttribs第一眼看起来复杂其实它就是在告诉 EGL我要一个红绿蓝透明度都是 8 位的 32 位颜色缓冲并且这个配置要支持 OpenGL ES 2.0还要能够作为窗口表面使用。如果某个设备不支持这个组合eglChooseConfig会返回 0 个配置所以后面一定要判断返回值。创建好 EGLCONtext 后GLES20.*这些指令才能在当前线程上使用。在调用任何 GL 指令前如果eglMakeCurrent失败后续所有操作都会出现GL_INVALID_OPERATION这是排查黑屏时最先要检查的环节。3. 从零手写替代方案SurfaceView 自建渲染线程实战理论说完直接进入实战。如果你打算在 Android 里彻底绕开GLSurfaceView最稳定、最接近原有体验的做法就是用一个普通的SurfaceView作为窗口自己开一条渲染线程在线程内初始化 EGL 并循环绘制。3.1 第一步拿到一块能画的原生 SurfaceSurfaceView本身会持有一块独立于窗口的 Surface通过SurfaceHolder可以拿到它。我们要做的就是实现SurfaceHolder.Callback接口在 Surface 创建、变化、销毁时得到通知。public class RenderSurfaceView extends SurfaceView implements SurfaceHolder.Callback { private RenderThread mRenderThread; public RenderSurfaceView(Context context) { super(context); getHolder().addCallback(this); } Override public void surfaceCreated(SurfaceHolder holder) { if (mRenderThread null) { mRenderThread new RenderThread(); mRenderThread.start(); } mRenderThread.surfaceAvailable(holder.getSurface(), getWidth(), getHeight()); } Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { if (mRenderThread ! null) { mRenderThread.surfaceSizeChanged(width, height); } } Override public void surfaceDestroyed(SurfaceHolder holder) { if (mRenderThread ! null) { mRenderThread.surfaceDestroyed(); } } public void release() { if (mRenderThread ! null) { mRenderThread.release(); mRenderThread null; } } }这看起来很接近GLSurfaceView的入口但区别在于surfaceCreated只是把“有 Surface 了”这个事件交给渲染线程并不强制你在 UI 线程创建 EGL 绑定。同时surfaceDestroyed也只是发信号真正销毁 EGL Surface 的动作发生在渲染线程里这样能避免在 UI 线程和渲染线程之间争夺 EGL 资源。在surfaceCreated里我会把holder.getSurface()传进去而不是等渲染线程自己再去拿。因为在surfaceDestroyed之后再次调用holder.getSurface()可能会得到异常或无效 Surface提前拿到引用更安全。3.2 第二步渲染线程与生命周期控制渲染线程是本方案的核心。它需要几个状态字段mRunning控制退出mSurface保存当前可用的 SurfacemWidth和mHeight保存 Surface 尺寸以及一个锁对象用于在 Surface 未就绪时休眠。private static class RenderThread extends Thread { private EGLDisplay mEglDisplay EGL14.EGL_NO_DISPLAY; private EGLContext mEglContext EGL14.EGL_NO_CONTEXT; private EGLSurface mEglSurface EGL14.EGL_NO_SURFACE; private final Object mLock new Object(); private volatile boolean mRunning true; private volatile Surface mSurface; private volatile int mWidth; private volatile int mHeight; Override public void run() { while (mRunning) { if (mSurface null) { synchronized (mLock) { try { mLock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } continue; } if (mEglContext EGL14.EGL_NO_CONTEXT) { if (!initEgl(mSurface)) { mSurface null; continue; } } // 每次循环先检查尺寸并设置视口 GLES20.glViewport(0, 0, mWidth, mHeight); drawFrame(); EGL14.eglSwapBuffers(mEglDisplay, mEglSurface); } releaseEgl(); } public void surfaceAvailable(Surface surface, int width, int height) { synchronized (mLock) { mSurface surface; mWidth width; mHeight height; mLock.notifyAll(); } } public void surfaceSizeChanged(int width, int height) { mWidth width; mHeight height; } public void surfaceDestroyed() { synchronized (mLock) { mSurface null; } } public void release() { mRunning false; synchronized (mLock) { mLock.notifyAll(); } try { join(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }几个关键细节我解释一下。mSurface null时线程会进入wait而不是空转死循环。这样既省电也避免了在 Surface 重建期间频繁去创建 EGL Surface。当surfaceCreated再次到来时notifyAll()会把渲染线程唤醒。mEglContext EGL14.EGL_NO_CONTEXT这个判断不能省。Surface 可能被销毁再创建但 EGL Context 并不需要重新创建它只是需要换一个新的 EGL Surface。如果每次surfaceCreated都重新创建 Context旧纹理、旧着色器句柄就全部失效了表现为重建后画面丢失资源。release()里调用join(1000)是为了确保线程退出前把 EGL 资源清理干净否则可能出现 Surface 已经销毁、线程还在交换缓冲的崩溃。3.3 第三步渲染主循环、视口与缓冲交换上面代码里的drawFrame()方法就是原先放在GLSurfaceView.Renderer.onDrawFrame里的逻辑。但有一点很容易被忽略每次进入渲染循环时Surface 的尺寸可能会变化所以必须先把glViewport设置好。否则你在一个宽度 1080、高度 1920 的 Surface 上继续使用上一帧的视口画面会被拉伸或者只渲染一部分。private void drawFrame() { GLES20.glClearColor(0, 0, 0, 1); GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT); // 这里画图像、三角形或任何你想要的内容 // drawImage(); drawTriangle(); }eglSwapBuffers是双缓冲的关键。OpenGL ES 的绘制结果默认不会直接显示到屏幕上而是画在后备缓冲里调用eglSwapBuffers时才把后备缓冲提交上去。如果不调用这句话你就算画得再热闹屏幕始终只会看到上一次swapBuffers的结果第一次则是黑屏。还有一个很多新手工伤的点渲染循环不应该全程拼命跑。如果是静态图像画完一遍后可以让线程休眠一小会儿或者干脆等下一次surfaceChanged再重绘。如果要做动画可以在drawFrame()里根据System.nanoTime()计算时间差来驱动进度而不是用Thread.sleep强行卡帧率。3.4 第四步把一张 Bitmap 变成 GL 纹理并上屏聊到“渲染图像”这个话题不能只停留在清屏。把一张 Bitmap 显示到 SurfaceView 上是绕不开的基本功。先把 Bitmap 上传成 GL 纹理再画一个全屏矩形。private int loadTexture(Bitmap bitmap) { int[] textureIds new int[1]; GLES20.glGenTextures(1, textureIds, 0); int textureId textureIds[0]; 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); GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, bitmap, 0); GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, 0); return textureId; }上传之后渲染线程里用一个最简单的着色器来画矩形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[] vertices new float[] { -1f, -1f, 1f, -1f, -1f, 1f, 1f, -1f, 1f, 1f, -1f, 1f }; float[] texCoords new float[] { 0f, 1f, 1f, 1f, 0f, 0f, 1f, 1f, 1f, 0f, 0f, 0f };注意我把texCoords的 Y 方向写反了。原因在于 Android 的 Bitmap 解码后内存布局默认是从上往下存储的而 OpenGL 纹理坐标系的原点在左下角。如果直接用0,0 对应左上角去采样图片会上下颠倒。这是个经典坑后面我们还会在glReadPixels里遇到同样的对齐问题。4. 进阶目标把图像渲到纹理、内存和视频帧上述流程解决的是“看得见的 Surface”上的渲染。但真实开发里有一半需求是看不见的把图像渲染进纹理、抓取渲染结果、把视频帧变成纹理。这些场景靠一个GLSurfaceView很难做顺如今我们已经有自己的渲染线程扩展起来就灵活多了。4.1 FBO离屏渲染最常用的画板FBOFrameBuffer Object是 OpenGL ES 里用来离屏渲染的标准工具。它的核心思想是创建一个只存在于 GPU 显存里的纹理然后把它挂在帧缓冲的COLOR_ATTACHMENT0上之后所有 GL 绘制都会画到这张纹理里而不是屏幕。// 创建一张空纹理作为离屏画布 int[] textures new int[1]; GLES20.glGenTextures(1, textures, 0); GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textures[0]); GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, width, height, 0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null); 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); // 创建 FBO 并挂接纹理 int[] fboIds new int[1]; GLES20.glGenFramebuffers(1, fboIds, 0); GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fboIds[0]); GLES20.glFramebufferTexture2D(GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0, GLES20.GL_TEXTURE_2D, textures[0], 0); // 必须检查完整性 int status GLES20.glCheckFramebufferStatus(GLES20.GL_FRAMEBUFFER); if (status ! GLES20.GL_FRAMEBUFFER_COMPLETE) { throw new IllegalStateException(Framebuffer not complete: status); } // 绘制结束后解绑 FBO之后的绘制才会回到窗口 Surface GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0);用 FBO 渲染后再切回窗口 Surface比重新走一遍 EGL Surface 创建销毁要快得多。你甚至可以在一个渲染循环里连续做“滤镜处理写入 FBO → 再从小 FBO 采样回窗口”形成滤镜链这就是很多美颜相机滤镜的渲染骨架。FBO 的大小要和需求匹配。如果你想把一张 1080p 的视频帧处理好再输出创建 1920x1080 的 FBO 纹理即可。需要注意glTexImage2D最后一个参数传null意思是只分配显存不填充数据这比传一个全零 ByteBuffer 更高效。4.2 PBUFFER用 EGL 直接创建不可见表面FBO 是 OpenGL ES 内部的离屏方案而 PBUFFER 是 EGL 层面的离屏 Surface。它不需要和任何窗口关联是一个完全不可见的像素缓冲表面。int[] pbufferAttribs new int[] { EGL14.EGL_WIDTH, 512, EGL14.EGL_HEIGHT, 512, EGL14.EGL_NONE }; EGLSurface pbufferSurface EGL14.eglCreatePbufferSurface( mEglDisplay, config, pbufferAttribs, 0); EGL14.eglMakeCurrent(mEglDisplay, pbufferSurface, pbufferSurface, mEglContext);之后你在同一个 Context 上绘制结果不会直接显示而是进入 PBUFFER 内存。由于它走的是 EGL Surface 路径某些早期设备上兼容性更好但现代 Android 开发中绝大多数离屏需求我都会优先用 FBO原因很直接FBO 不依赖额外 EGL Surface纹理可以直接作为采样输入参与后续绘制省去一次拷贝。PBUFFER 还有一个使用场景值得记住如果你需要在一个后台线程里提前创建 EGL Context又暂时没有窗口 Surface可以先创建一个 1x1 的 PBUFFER 来做上下文预热之后再换绑到真正的窗口 Surface。这样后续创建窗口 Surface 时上下文已经热好了能减少首帧启动时间。4.3 SurfaceTexture外部图像流进入 GL 的桥真正让 OpenGL ES 和 Android 多媒体体系打通的是SurfaceTexture。它可以这样理解一块由 GL 纹理构成的“外接屏幕”Camera、MediaPlayer 等组件把帧数据写进这块屏幕GL 侧拿到一个时间点上的最新帧把它采样到自己的绘制管线里。它本身不自带渲染线程非常适合配合我们自建的渲染循环。int[] textureIds new int[1]; GLES20.glGenTextures(1, textureIds, 0); SurfaceTexture surfaceTexture new SurfaceTexture(textureIds[0]); surfaceTexture.setOnFrameAvailableListener(listener); // 把 surfaceTexture 包装成 Surface交给视频播放器或相机 Surface surface new Surface(surfaceTexture); // mediaPlayer.setSurface(surface); // camera.setPreviewTexture(surfaceTexture);在渲染线程中收到帧可用通知后调用surfaceTexture.updateTexImage()更新纹理内容并用getTransformMatrix()拿到变换矩阵。这里有个坑必须强调这不是一张普通的GL_TEXTURE_2D纹理而是GL_TEXTURE_EXTERNAL_OES着色器里不能直接用sampler2D。#extension GL_OES_EGL_image_external : require precision mediump float; uniform samplerExternalOES uTexture; varying vec2 vTexCoord; void main() { gl_FragColor texture2D(uTexture, vTexCoord); }这个坑我踩过不止一次第一次把视频纹理绑定到GL_TEXTURE_2D上画结果画面是纯黑因为视频帧数据根本没有写入那块纹理换成GL_TEXTURE_EXTERNAL_OES后立刻就好了。4.4 把渲染结果捞回 Bitmap 的办法有时候渲染的最终目标是保存图片或截图反馈给用户而不是上屏。两种常见做法一是用 FBO 渲染进纹理然后glReadPixels读出来转成 Bitmap二是渲染时把窗口 Surface 换成 PBUFFER再读取内容。ByteBuffer buffer ByteBuffer.allocateDirect(width * height * 4); buffer.order(ByteOrder.nativeOrder()); GLES20.glReadPixels(0, 0, width, height, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, buffer); Bitmap bitmap Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); bitmap.copyPixelsFromBuffer(buffer);这段代码读出来的是上下颠倒的。因为glReadPixels从 OpenGL 的左下角起始读取而 Android Bitmap 的存储是从左上角开始。最简单的修法是让 Bitmap 进入 canvas 后做一次翻转或者用矩阵变换翻转不要傻傻地逐行去颠倒字节效率很低。另一个细节是缓冲对齐。默认情况下glReadPixels每一行的字节数会按 4 字节对齐如果宽高和 RGBA 组合正好是 4 的倍数当然没问题但万一遇到宽 600、格式 RGB565 这类情况计算结果要自己处理行填充否则读出来的图像会出现一条条斜线或错位。最稳妥的方式是渲染到 RGBA 格式的 FBO 再读取让每像素 4 字节、每行天然对齐。5. 常见问题与排查技巧实录下面这些问题我在使用GLSurfaceView时几乎不会遇到但弃用它之后简直就是新手进阶的必经之路。一个一个梳理清楚能帮大家省很多调试时间。5.1 黑屏别一上来就怀疑着色器自建渲染线程最常见的现象是“代码没错但屏幕全黑”。无论你先怀疑谁的 bug都建议按这个顺序排查eglInitialize是否成功。eglChooseConfig是否返回了配置。eglCreateWindowSurface是否成功。eglMakeCurrent是否成功。渲染循环是否真的调用了eglSwapBuffers。大部分黑屏问题的真凶不在着色器而在 EGL 初始化断在了某一步。我习惯在每一步之后加一个EGL14.eglGetError()检查把错误码输出到日志。比如EGL_BAD_NATIVE_WINDOW就说明窗口 Surface 已经失效EGL_BAD_CONFIG就说明你筛选的配置在当前设备上不存在。黑白屏还有一个隐藏原因绘制了一个三角形或矩形但顶点坐标和视口不匹配。比如视口是 1080x1920你却在窗口坐标里画了一个顶点范围 -10 到 10 的图形结果图形画在视口外的区域。先清屏后再画如果清屏颜色能看到基本就能确认 EGL 管线是通的问题在绘制部分。5.2 EGL_BAD_NATIVE_WINDOW生命周期错位这个错误我几乎每次重构时都会遇到原因是SurfaceHolder.Callback的回调发生在 UI 线程而 EGL 资源初始化发生在渲染线程。当 Activity 暂停时UI 线程可能已经调用了surfaceDestroyed但渲染线程还在用旧的Surface尝试eglSwapBuffers或新创建 EGL Surface。解决办法是把所有 EGL Surface 的创建、销毁操作都放到渲染线程内执行UI 线程只负责设置状态和唤醒线程。比如surfaceDestroyed里只把mSurface置空渲染线程察觉到后立刻执行eglDestroySurface和eglMakeCurrent(display, EGL_NO_SURFACE...)。千万不要直接在回调里调用eglReleaseThread或销毁上下文那样会引发跨线程调用 EGL 的崩溃。还有一个小细节surfaceDestroyed回调时SurfaceHolder 提供的 Surface 对象已经被标记为销毁如果你在回调里同步去eglCreateWindowSurface大概率失败。所以前面我推荐在surfaceCreated时就把 Surface 引用交给线程避免后知后觉。5.3 Surface 尺寸和 View 尺寸不一致导致的视口错乱SurfaceView的内部 Surface 尺寸并不一定等于getWidth()和getHeight()尤其在屏幕方向变化、系统状态栏折叠展开、或者 SurfaceView 被放大缩小时差别会很明显。如果你只用getWidth()去设置glViewport画面会出现白边、拉伸或明显偏移。因此视口的宽高应该以SurfaceHolder.Callback.surfaceChanged提供的width和height为准或者直接在渲染线程里读 Surface 的实际尺寸。我在surfaceSizeChanged里保存了实时尺寸并在每次渲染前设置视口就是为了避免这种错位。还有一个经验不要假设 GPU 的输出分辨率必须等于 UI 控件的逻辑分辨率。有些高刷新率设备上Surface 的分辨率会比 View 逻辑尺寸大你需要用 Surface 的实际像素尺寸去判断纹理和视口而不是 View 的 dp 尺寸。5.4 纹理上下颠倒和一行黑边在 Android 上使用 Bitmap 纹理时颠倒几乎是个默认现象。原因前面已经说过OpenGL 纹理坐标原点在左下角而 Android Bitmap 内存顺序是自上而下。三种处理方式在顶点着色器阶段把纹理坐标的 Y 翻转。上传纹理前用Matrix把 Bitmap 做垂直翻转。在渲染四边形时把纹理坐标从0,0改为0,1。第二种方式最省心但多一次拷贝第一种方式最常用。如果翻转后图片顶部出现一条细黑边多半是GL_CLAMP_TO_EDGE没有设置。GL_REPEAT模式下纹理边缘会被重复填充出现黑线改成GL_CLAMP_TO_EDGE后边缘像素就会正常复制。5.5 在 UI 线程上碰 GL 的后果自建线程后最大的纪律问题不要把 GL 调用写在 UI 线程里。很多人在SurfaceHolder.Callback回调里顺手调glGenTextures或者为了读取onDrawFrame的结果直接在 Activity 里调用glReadPixels结果要么崩溃要么拿到的是上一次绑定的旧数据。GL 调用必须和eglMakeCurrent发生在同一个线程。如果你有 UI 线程交互需求正确做法是给渲染线程设计一个任务队列把“生成纹理”“读取像素”“切换纹理”这些动作 post 到渲染线程执行。在我的渲染线程里会放一个Handler或简单的工作队列这样既保证了资源操作的单线程性又不会阻塞 UI。6. 一些私人体会与建议自己搭完一遍渲染底座后我最大的感受是GLSurfaceView并不差但它把很多事情藏得太深了。一旦业务规模越过“显示一张图”的边界理解 EGL 的基本流程就变成必需品。我习惯在做完所有功能后给渲染线程加一个小工具方法把任意一个Runnable投递到渲染线程执行。这个方法非常简单但调试时特别管用因为你可以随时用一行代码在正确的 GL 线程里查资源状态、做纹理操作不用打断整个线程模型。public void postToRenderThread(Runnable task) { mHandler.post(task); }这里面的mHandler可以在创建渲染线程时用HandlerThread的 Looper 来初始化。如果嫌HandlerThread太繁琐也可以维护一个BlockingQueueRunnable在渲染循环里每次取一个任务执行。重要的是保证任务执行线程和 EGL 绑定线程是同一个。最后再分享一个小建议如果只是验证思路可以把自建渲染循环做成一个单独的工具类不依赖具体的 Activity 或 Fragment以后在好几个项目里都能复用。这个工具类持有 Surface、EGL 上下文、尺寸状态和渲染回调接口业务层只用关心onDrawFrame画什么。等这套东西稳定之后你会发现自己对 OpenGL ES 的信心比之前强了不止一个档次。
RELATED

相关推荐

2025届AI学术平台实测:哪些论文环节真正值得用?

2025届AI学术平台实测:哪些论文环节真正值得用?

1. 先用一句话回答:2025届AI学术平台的“实际效果”落在哪个环节最近后台留言里,出现频率最高的一句话是:2025届是不是真的离了AI学术平台就毕不了业?我连续两周,拿同一个课题去测了市面上一批AI学术工具,从…

📅 2026/10/21 6:51:02
真空烘箱精密控温:防氧化与数据追溯全解析

真空烘箱精密控温:防氧化与数据追溯全解析

摘要:新一代工业级真空烘箱如何实现精密控温?本文围绕三大机制展开:负压辐射加热实现深层脱水与防氧化、干式螺杆泵与闭环保压杜绝油污返吸、PLC 可编程控温与数据导出构建完整数据追溯链,并剖析其适用边界,助你科学选…

📅 2026/10/21 6:51:02
lingbot-map:轻量级位置语义解析与空间建模技术

lingbot-map:轻量级位置语义解析与空间建模技术

1. “lingbot-map”不是某个现成工具,而是一类需求的命名共识第一次在某跨平台系统开发群看到“lingbot-map”这个词时,我下意识去 npm 和 PyPI 搜了三遍,又翻了 GitHub Trending 前 50 页——没有官方仓库,没有文档站&#xff0c…

📅 2026/10/21 6:46:02
MORE NEWS

更多资讯

📰

专科生论文冲刺:千笔与Checkjie实测对比与组合使用指南

说实话,每年到这个节点,我后台私信里堆得最多的就是论文求助。尤其专科阶段,实习、备考、毕业设计全挤在一起,真正留给论文的时间可能就一两周。2026年这一批同学压力更大,很多学校把论文提交时间前移了,查…

📰

Formbook多波次钓鱼攻击的技术演化与纵深防御实践

Formbook这个样本家族,在钓鱼邮件攻击里算是老面孔了,但从我近几年处置的几起事件来看,它从来没打算退出舞台,反而一直在悄悄换打法。过去我们对付它的思路,基本是抓样本、提哈希、封域名,一套组合拳打完就…

📰

ASM磁盘组不完整导致spfile创建失败:ORA-15040排查与恢复

1. 故障现场:一条spfile命令引发的连环报错做DBA这行,几乎每个人早晚都会撞上“只是想建一个spfile,结果数据库却连环报错”的场景。上周在某个生产环境的备库切换演练里,我执行一条CREATE SPFILE命令,控制台连续弹出了…

📰

STM32智能小车实战:PWM调速、循迹、避障、跟随与红外遥控设计

简介:这是一份基于STM32F103ZET6单片机的智能小车系统设计论文,系统讲解PWM调速、红外循迹、红外避障、超声波避障、障碍物跟随以及红外遥控等功能的实现方法,适合单片机、嵌入式方向的在校学生,以及需要完成课程设计或毕业设计的…

📰

波形和文稿对不齐,播客剪辑台怎样让字幕跟着声音走

编辑 EP.028 那期《城市夜航》的时候,我把播放头拖到 08:24,“智能文稿"里显示的是"我们开始意识到,声音不只是内容的载体……”,可波形上对应的却是两秒前嘉宾的尾音。字幕和声音之间始终差着一小段,而且越…

📰

C#火车订票系统:WinForm+SQL Server全链路实战指南

简介:这是一套基于C#与SQL Server开发的完整火车订票系统实战项目资源,面向C#初学者及.NET桌面应用入门学习者,聚焦GUI开发、数据库交互与多模块业务逻辑实现。资源包含128个文件,主体为50个C#源码文件(.cs&#xff09…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬