简介:OpenGLESDemo.zip 是一份面向 Android 平台的 OpenGL ES 图形开发示例合集,适合具备一定 Java 与 Android 基础、希望系统掌握移动端 2D/3D 渲染的开发者。内容围绕基本图元渲染、GLSL 顶点与片段着色器、纹理映射与 MIP 贴图、视口与正交/透视投影、光照与深度测试、帧缓冲对象与后期处理、混合模式、动画帧率控制等主题展开,并延伸至 VR 场景下的双目渲染、立体视图与视差处理。压缩包共 1245 个文件,约 12.63MB,以 44 个 java 源码与 521 个 flat、204 个 dex、202 个 class 等编译运行产物为主,辅以 18 个 xml 配置、6 个 glsl 着色器脚本、18 个 png 与 3 个 jpg 贴图、3 个 gradle 构建文件及 json、properties 工程配置,构成一套结构完整的可运行工程。目前已有 249 人学习下载,可对照各主题示例逐个实践,理解渲染管线的搭建过程与着色器编写方法,也是排查黑屏、贴图错位、深度冲突等常见问题的参考底稿。
1. 把 OpenGL ES Demo 跑通,比读十篇教程都值
接手一个图形模块,最怕的不是需求复杂,而是整个工程里没有一帧画面能正常跑起来——没有可以对照的基线,任何黑屏都成了难以定位的问题。OpenGLESDemo.zip 就是这样一个基线工程:它用最少的代码把 OpenGL ES 的最小闭环串起来,从 EGL 上下文创建、着色器编译链接、VBO 顶点上传、纹理采样,一直走到左右眼各画一遍的 VR 双目输出。适合两类人:刚接触移动端图形、想找一个能跑通再改的例子;以及要做相机预览、全景播放、简易 VR 盒子,需要一个干净的渲染骨架往上加功能。后面按工程结构一层层拆开,每段代码都说明为什么这么写、参数该怎么改。
2. 工程骨架与 EGL 上下文:一帧画面是怎么被推上屏幕的
拿到压缩包解开之后,先把目录过一遍再动手改代码,这一步能省掉后面很多“改了没反应”的困惑。这类 Demo 工程通常不会有太多花哨的分层,但职责边界是清楚的:谁管生命周期、谁管资源、谁管绘制,分得比较干净。
2.1 目录结构:三层职责划得很清楚
解压后的主干大致是这样一份骨架,命名可能略有差异,但职责划分基本一致:
| 路径 | 职责 | 改动频率 |
|---|---|---|
app/src/main/java/.../gl/GlSurfaceRenderer.java | 渲染主循环,实现onSurfaceCreated/onSurfaceChanged/onDrawFrame | 高 |
app/src/main/java/.../gl/EglCore.java | EGL 显示、配置、上下文的创建与销毁 | 低 |
app/src/main/java/.../gl/WindowSurface.java | 封装EGLSurface的创建、swapBuffers、宽高查询 | 低 |
app/src/main/java/.../gl/ShaderProgram.java | 着色器编译、链接、属性与 uniform 句柄缓存 | 中 |
app/src/main/assets/shader/*.vert / *.frag | GLSL 源码文本 | 高 |
app/src/main/cpp/ | 可选,把 EGL 与绘制下沉到 Native 层 | 中 |
app/src/main/res/raw/ | 贴图、视频帧、预畸变网格数据 | 中 |
一个常见的小决策是着色器放哪:直接写死在 Java 字符串里,改一行要重新编译整个 APK;放assets/shader/下用AssetManager读文本,改完重装即可,调试期舒服很多。我一般还会留一个“热重载”的口子,在 Debug 包里从外部目录读.frag,省去反复改文件路径的时间,这一招在调片元着色器时特别有用。
Java 层负责跟界面生命周期打交道,Native 层负责跟 GL 打交道,中间靠一个共享的EGLContext打通。如果工程里同时有cpp/和java/两套,重点是确认它们用同一个EGLContext句柄,否则纹理和 FBO 会在两边互相看不见。
2.2 EGL 初始化:五步走完才有 GL 上下文
很多人卡在第一步:glCreateShader一调用就返回 0,或者glGetError永远是GL_INVALID_OPERATION。原因往往不是着色器写错了,而是当前的线程上根本没有 make 过 current 的上下文。EGL 的创建顺序是固定的,跳步就会出错:
// EglCore.java —— 最小可用的 EGL14 初始化 public EglCore(EGLContext sharedContext, int flags) { mEGLDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); if (mEGLDisplay == EGL14.EGL_NO_DISPLAY) { throw new RuntimeException("eglGetDisplay failed"); } int[] version = new int[2]; // 版本号是出参,顺便确认驱动至少支持到 EGL 1.4 if (!EGL14.eglInitialize(mEGLDisplay, version, 0, version, 1)) { throw new RuntimeException("eglInitialize failed"); } int[] attribList = { EGL14.EGL_RED_SIZE, 8, EGL14.EGL_GREEN_SIZE, 8, EGL14.EGL_BLUE_SIZE, 8, EGL14.EGL_ALPHA_SIZE, 8, // 要跟系统 UI 叠加就必须留 alpha 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]; // 注意:这里传 configs.length 而不是 1,让驱动按能力挑 EGL14.eglChooseConfig(mEGLDisplay, attribList, 0, configs, 0, configs.length, numConfigs, 0); if (numConfigs[0] <= 0) { throw new RuntimeException("no EGLConfig matched"); } mEGLConfig = configs[0]; int[] ctxAttribs = {EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE}; mEGLContext = EGL14.eglCreateContext(mEGLDisplay, mEGLConfig, sharedContext == null ? EGL14.EGL_NO_CONTEXT : sharedContext, ctxAttribs, 0); checkEglError("eglCreateContext"); }参数上有几个地方值得停一下。EGL_CONTEXT_CLIENT_VERSION写 2 意味着着色器里第一行要写#version 100(或者不写,默认就是 100),如果哪天想升级到 ES 3.0,这里改成 3,同时所有着色器加#version 300 es,并且把attribute/varying换成in/out,不能只改一边。EGL_ALPHA_SIZE是很多工程漏掉的一项,漏了之后swapBuffers出来的表面是不透明的,做叠加层时会直接看到黑底。
上下文有了,还要把 surface 绑上去,这一步通常封在WindowSurface里:eglCreateWindowSurface拿Surface或SurfaceTexture包一层,eglMakeCurrent把上下文和绘制表面绑到当前线程,eglSwapBuffers提交一帧。这三步必须在同一个线程里完成,跨线程调用的结果是EGL_BAD_SURFACE,报错信息还特别模糊,属于典型的故障。
2.3 GLSurfaceView 还是自建 EGL 线程
Demo 里两种写法都可能出现。GLSurfaceView的好处是省事:setEGLContextClientVersion(2)加setRenderer(),渲染线程、上下文丢失重建,框架都替你处理了。缺点是渲染节奏被它控制,RENDERMODE_CONTINUOUSLY和RENDERMODE_WHEN_DIRTY二选一,做 VR 或者需要跟传感器对齐帧时间的时候,默认的 60Hz 回调就不太够用。
自建 EGL 线程的写法多几十行代码,换来的是完全的控制权——自己决定什么时候画、画完什么时候swapBuffers、要不要和相机的onFrameAvailable同步。做相机预览和 VR 这类场景,我更倾向自建,因为你可以把“新帧到达”当成渲染触发条件,不浪费一次 GPU 绘制。
// 用 GLSurfaceView 时,先把渲染模式定下来再 setRenderer glSurfaceView.setEGLContextClientVersion(2); glSurfaceView.setEGLConfigChooser(8, 8, 8, 8, 16, 0); // RGBA8888 + 16 位深度 glSurfaceView.setRenderer(renderer); glSurfaceView.setRenderMode(GLSurfaceView.RENDERMODE_CONTINUOUSLY); // VR 必须连续深度位数这里填 16,够用而且省带宽;如果画面里有多层半透明或者要画大量交叠几何体,再考虑 24。setEGLConfigChooser必须在setRenderer之前调用,反过来调会被忽略,而且不会有任何提示,这种“静默失效”是最容易产生的失误。
生命周期上,onPause里调glSurfaceView.onPause(),onResume里调onResume(),这不是可选项。后台待久了上下文会被系统回收,回来之后所有 GL 资源句柄全部失效,纹理会变成白板,程序也不崩——只是画面不对。常见做法是在onSurfaceCreated里重建所有资源,把创建逻辑集中到一个initGl()方法里,别散落在各个回调中。
3. 着色器与渲染管线:从一段 GLSL 到屏幕上的一个像素
三角形是这套 Demo 的“Hello World”,但真正要改的东西在后面:贴图、相机流、双目。这几条路径共用同一个管线,区别只在输入和 uniform。把这条链路跑通,后面加什么都是换汤不换药。
3.1 着色器编译与链接:错误信息比想象中重要
编译失败是新手最高频遇到的问题,而它的调试成本几乎全部取决于你有没有用glGetShaderInfoLog。这方法很多人写 Demo 时省掉了,结果只是看到一个0,完全不知道为什么。
// ShaderProgram.java —— 编译 + 链接,错误日志原样打出来 private static int compileShader(int type, String source) { int shader = GLES20.glCreateShader(type); GLES20.glShaderSource(shader, source); GLES20.glCompileShader(shader); int[] compiled = new int[1]; GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0); if (compiled[0] == 0) { // 日志里带行号,直接定位到出错那一行,别自己猜 String log = GLES20.glGetShaderInfoLog(shader); GLES20.glDeleteShader(shader); throw new RuntimeException("compile failed: " + log); } return shader; } public void link() { int vs = compileShader(GLES20.GL_VERTEX_SHADER, mVertexSource); int fs = compileShader(GLES20.GL_FRAGMENT_SHADER, mFragmentSource); mProgram = GLES20.glCreateProgram(); GLES20.glAttachShader(mProgram, vs); GLES20.glAttachShader(mProgram, fs); GLES20.glLinkProgram(mProgram); int[] status = new int[1]; GLES20.glGetProgramiv(mProgram, GLES20.GL_LINK_STATUS, status, 0); if (status[0] == 0) { throw new RuntimeException("link failed: " + GLES20.glGetProgramInfoLog(mProgram)); } // 链接成功后 shader 对象就可以删了,program 已经持有编译产物 GLES20.glDeleteShader(vs); GLES20.glDeleteShader(fs); }片元着色器有一个绕不过去的要求:必须显式声明浮点精度,precision mediump float;或者highp。桌面 GL 默认有精度,移动端没有,缺这一行在部分驱动上直接编译失败,而在另一些驱动上又能跑,这也是同一份代码换个机型就出问题的常见来源。mediump在大多数场景够用且快,涉及世界坐标或者累积运算的地方再上highp。
链接成功后调glGetAttribLocation/glGetUniformLocation拿句柄,这一步有个值得注意的点:没被用到的属性会被编译器优化掉,返回 -1。给 -1 传数据不会崩,但也不会有效果,画面上就是“某个顶点属性没生效”。建议在link()之后统一校验一遍必需属性,缺了就在 Debug 包里抛出来。
3.2 顶点数据:VBO 上传与属性绑定
每次绘制都用glVertexAttribPointer传一个 Java 的FloatBuffer能跑,但每帧上传一次顶点数据纯属浪费,数据是不变的。正确姿势是初始化时上传一次 VBO,绘制时只绑 VAO(ES 3.0)或重新glVertexAttribPointer(ES 2.0)。
// 一个带纹理坐标的全屏四边形,VR 和视频渲染基本都是这一块 private static final float[] QUAD = { // x, y, u, v -1f, -1f, 0f, 0f, 1f, -1f, 1f, 0f, -1f, 1f, 0f, 1f, 1f, 1f, 1f, 1f, }; private void uploadQuad() { mVertexBuffer = ByteBuffer.allocateDirect(QUAD.length * 4) .order(ByteOrder.nativeOrder()).asFloatBuffer(); mVertexBuffer.put(QUAD).position(0); int[] vbo = new int[1]; GLES20.glGenBuffers(1, vbo, 0); GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, vbo[0]); // GL_STATIC_DRAW 告诉驱动这块数据不会频繁改,可以放到显存 GLES20.glBufferData(GLES20.GL_ARRAY_BUFFER, QUAD.length * 4, mVertexBuffer, GLES20.GL_STATIC_DRAW); }GL_STATIC_DRAW是个提示而不是规则,驱动完全可以忽略,但给对了能让它把数据挪到更快的内存里。如果顶点每帧都变(比如动态网格),换成GL_DYNAMIC_DRAW,并且考虑glBufferSubData局部更新而不是整块重传。
属性绑定的顺序也有讲究:先glUseProgram,再glEnableVertexAttribArray(loc),再glVertexAttribPointer。顺序错了属性不会生效,且不报错。这个错误隐蔽到什么程度——顶点位置对、UV 全零,画出来就是一片纯色,从画面上完全看不出是绑定问题。
3.3 纹理上传:采样参数决定画面是否正常
纹理是这套 Demo 里最容易出现“看着像坏了又好像没坏”的地方。上传本身不复杂,麻烦的是采样参数和行对齐:
public int loadTexture(Bitmap bitmap) { int[] tex = new int[1]; GLES20.glGenTextures(1, tex, 0); GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, tex[0]); // 很多贴图宽度不是 4 的倍数,不设这一行会出现整体斜移的彩条 GLES20.glPixelStorei(GLES20.GL_UNPACK_ALIGNMENT, 1); 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); // 非 2 次幂纹理只能 CLAMP_TO_EDGE,用 REPEAT 会整块变黑 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 tex[0]; }GL_UNPACK_ALIGNMENT默认是 4,意思是每一行像素数据按 4 字节对齐。RGB888 的图宽度不是 4 的倍数时,逐行读取就会错位,表现出来的就是斜着的彩色条纹,看着像驱动坏了。设成 1 就解决了,代价几乎为零。
另外,GL_TEXTURE_MIN_FILTER如果设成GL_LINEAR_MIPMAP_LINEAR却没有生成 mipmap,纹理就是不完整的,采样结果永远是黑色。这一条在新手代码里出现频率极高,因为没人会想到“过滤器选错导致全黑”。
3.4 相机流这条路:OES 外部纹理不能当普通纹理用
如果 Demo 里带相机预览,那它用的多半是GL_TEXTURE_EXTERNAL_OES。相机出的是 YUV 数据,由驱动直接转成外部纹理,全程不经过 CPU 拷贝,这是移动端预览能做到低延迟的关键。但它跟普通 2D 纹理有两处硬性差异:着色器里必须声明扩展,采样器类型也不同。
// oes_camera.frag —— 相机预览专用,普通纹理用不了这个采样器 #extension GL_OES_EGL_image_external : require precision mediump float; uniform samplerExternalOES uTexture; uniform mat4 uTexMatrix; // SurfaceTexture 提供的变换矩阵 varying vec2 vTexCoord; void main() { // 少了这一步,画面会拉伸或者转 90 度 vec2 uv = (uTexMatrix * vec4(vTexCoord, 0.0, 1.0)).xy; gl_FragColor = texture2D(uTexture, uv); }uTexMatrix是整套流程里最容易被忽略的一个 uniform。相机传感器方向和屏幕方向不一致,SurfaceTexture.getTransformMatrix()返回的矩阵就是用来把采样坐标纠正过来的。不传这个矩阵,很多机型上画面会横过来或者比例拉长,而且换台设备表现又不一样,很像是“玄学”。
调用顺序上,updateTexImage()必须在 GL 线程里调,且要在glBindTexture之后、绘制之前。放在主线程调,画面会撕裂或者直接停住,日志里什么都不会有——这类问题只能靠对线程模型的正确理解来避免。
4. 矩阵与 VR 双目:把一帧画面拆成左右两份
单眼渲染只关心“投影对不对”,双目渲染还要关心“两只眼睛看到的是不是同一个世界里的同一点”。这一步做不好,轻则头晕,重则完全没有立体感,参数调半天也找不到原因。
4.1 MVP 与透视投影:参数到底怎么填
模型、视图、投影三个矩阵乘起来,是每帧都要算一次的东西:
// 透视投影:fov 用垂直视场角,aspect 是宽高比 // 竖屏时 aspect = height / width,填反了画面会被压扁 float aspect = (float) height / (float) width; Matrix.perspectiveM(projM, 0, 60f, aspect, 0.1f, 100f); Matrix.setLookAtM(viewM, 0, 0f, 0f, 0f, // 眼睛位置 0f, 0f, -1f, // 看向 -Z 0f, 1f, 0f); // 上方向 Matrix.multiplyMM(vpM, 0, projM, 0, viewM, 0);fov填 60 是个常见默认值,但做 VR 时这个值要和镜片的光学参数对上,填大了画面边缘会溢出、填小了视野被裁掉一圈。near别设太小,0.001 这种值会把深度缓冲的精度吃光,远处的面开始闪烁打架,学名叫 z-fighting,表现是“表面有噪点、纹理抖动”。0.1 到 1.0 是移动端比较安全的区间。
aspect是竖屏还是横屏,取决于你把画面当什么用。做全景或者视频,通常按纹理的比例算,不是按屏幕算,否则素材会被拉伸。
4.2 双目视口:左右各画一遍,差的只是位置
VR 的本质没那么复杂:同一套几何体画两遍,每遍的视口占屏幕一半,视图矩阵在 X 轴上各偏移半个瞳距。
// 成人瞳距取 62mm,一半就是 31mm,单位要和场景保持一致 final float HALF_IPD = 0.031f; for (int eye = 0; eye < 2; eye++) { int offsetX = eye * width / 2; // 先切视口,再清屏。顺序反了会把另一只眼的画面也清掉 GLES20.glViewport(offsetX, 0, width / 2, height); float sign = (eye == 0) ? -1f : 1f; Matrix.setIdentityM(viewM, 0); Matrix.translateM(viewM, 0, sign * HALF_IPD, 0f, 0f); Matrix.multiplyMM(vpM, 0, projM, 0, viewM, 0); GLES20.glUniformMatrix4fv(uMvpLoc, 1, false, vpM, 0); GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); }几点值得说明。glViewport的四个参数是(x, y, width, height),原点在左下角,所以左眼从 0 开始、右眼从width/2开始。glClear应该在循环外面调一次,视口内部不要再清,否则一只眼画完会被另一只眼清掉。瞳距的正负方向如果反了,看到的会是“反立体”——近处的物体看起来在远处,脑子会很不舒服,但画面本身看不出任何异常。
还有个坑是视口切了但没恢复。如果后面还有别的绘制(比如叠加一层 UI 或者 FPS 文字),它会挤在右半屏里,因为视口还停在width/2上。每段独立绘制前重设一次glViewport是成本最低的保险。
4.3 畸变校正与性能预算:先保证帧率再谈画质
手机 VR 盒子用的是普通透镜,画面会有桶形畸变,边缘还会色散。正规做法是拿一组网格顶点,预先按镜片参数做反向畸变,再把渲染结果贴到网格上。Demo 里通常会给一个简化的版本,或者干脆不做,直接看能不能跑起来。
性能上,双目意味着绘制调用翻倍、像素填充翻倍。真正拖慢帧率的往往不是 draw call 数量,而是分辨率和过度绘制。几个实测有效的做法:
- 每只眼的分辨率按屏幕一半算,别为了“清晰”把 render target 开到 2K,中端机直接掉到 30 帧以下。
- 关掉
GL_DITHER、不开 MSAA,除非你确认目标机型扛得住。 - 别在渲染循环里调
glReadPixels或glFinish,这两个都会强制同步,帧率直接腰斩,属于典型的“看起来无辜实际上致命”。 - 用
Choreographer或者eglSwapBuffers的返回时机对齐传感器采样,能明显减少画面拖影。
预算怎么定:中端机每眼 1080×1200 左右、单眼绘制 10 万顶点以内,通常能跑到 55 到 60 帧。超了这个量级,先砍几何体数量,再考虑降分辨率,顺序不要反。
5. 跑起来与踩坑排查:黑屏、花屏、闪退的定位顺序
Demo 跑不起来的时候,最忌讳的是漫无目的地改代码。排查有顺序:先确认环境,再看日志,最后才怀疑着色器和数据。
5.1 环境准备与首次运行
工程如果是 Java 单层实现,只需要 SDK;带cpp/目录的,还要装 NDK 并在build.gradle里配好externalNativeBuild的 CMake 路径。移动端图形代码基本没法在模拟器上验证,模拟器的 GL 实现是软件转译的,行为跟真机差得很远,务必上真机。
# 命令行编译安装,比 IDE 少一层干扰 ./gradlew :app:installDebug # 只抓图形相关的日志,过滤掉系统噪音 adb logcat -s libEGL:V OpenGLRenderer:V Adreno:V Mali:V # 看设备支持的 GLES 版本和扩展,确认 OES 纹理可用 adb shell dumpsys SurfaceFlinger | grep -i gles日志里出现eglChooseConfig相关报错,说明配置属性太苛刻,先去掉EGL_ALPHA_SIZE和深度位试一次。出现GL_INVALID_OPERATION且频率很高,八成是某个线程没有 make current。
5.2 常见问题排查:五个反复出现的问题
黑屏,但没有任何报错
现象:应用正常启动,界面是纯黑或者纯背景色,logcat干净得可疑。
原因:没调glClear,或者清了但颜色就是黑色;另一种常见情况是glViewport传了 0 宽 0 高(onSurfaceChanged里宽高拿的是还没测量的值),所有绘制都被裁掉了。
解决:在onDrawFrame开头固定写上glClearColor(0.2f, 0.2f, 0.2f, 1f)加glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT),如果背景变成了灰色,说明管线是通的,问题在绘制阶段;如果还是黑的,问题在 EGL 或 swap 环节。
着色器编译失败,但只在部分机型上
现象:手上两台设备,一台正常,一台白屏或直接崩。
原因:片元着色器漏了precision mediump float;;或者用了高版本语法(in/out、texture())却没把上下文版本改成 3;还有一种是循环里用了非恒定边界,ES 2.0 不允许。
解决:把glGetShaderInfoLog的内容原样打出来,驱动会明确告诉你哪一行有问题,通常还会带上“precision”字样。这一条是必须记住的处理经验——不打日志的调试等于闭眼修车。
纹理花屏、出现斜向彩条
现象:贴图整体错位,像是被斜着切了一刀。
原因:GL_UNPACK_ALIGNMENT还是默认的 4,而图片宽度乘每像素字节数不是 4 的倍数。
解决:上传前glPixelStorei(GL_UNPACK_ALIGNMENT, 1)。设完记得别在别处又改回去,这是个全局状态。
相机预览黑屏或绿屏
现象:权限给了,相机也打开了,画面就是黑的或者一片绿。
原因:纹理绑错了 target——相机流要用GL_TEXTURE_EXTERNAL_OES,绑成GL_TEXTURE_2D不会报错但永远采不到数据;或者updateTexImage()被放在了非 GL 线程。
解决:检查glBindTexture的第一个参数和着色器里的samplerExternalOES是否对应;把updateTexImage()挪进 GL 线程,并且确保它发生在glBindTexture之后、glDrawArrays之前。
从后台切回来,画面变白或者全黑
现象:第一次进正常,按 Home 再回来就废了。
原因:EGL 上下文被系统回收,之前创建的纹理、VBO、program 全部失效,句柄变成了野值。
解决:把所有 GL 资源创建集中到onSurfaceCreated调用的initGl()里,切回来时重新走一遍。不要在构造函数或者onCreate里建 GL 资源,那时候上下文还不存在。
6. 二次开发:把 Demo 拆成能复用的渲染模块
Demo 能跑之后,真正的工作才开始。我一般不会直接在原来的Renderer上改,而是先做一层抽象,把“画什么”和“怎么画”分开。
public interface IRenderPass { void onSurfaceCreated(); // 建 program、纹理、VBO void onSurfaceChanged(int w, int h); // 重建投影矩阵和视口 void drawFrame(float[] viewM, float[] projM, long frameTimeNs); void release(); // 上下文销毁前回收 }有了这层接口,一个CameraPass、一个PanoramaPass、一个OverlayPass可以独立开发和验证,主循环只负责按顺序调用。我通常还会加一个ShaderRepository,按文件名缓存已经编译好的 program,同名只编译一次,改一个片元着色器不用把整条管线重建一遍。
改造时最容易犯的错是:以为改了 Java 代码就生效,实际上assets里的 GLSL 是打包进 APK 的,改了要重装。所以在 Debug 包里我一般会留一个从外部路径读着色器的分支,BuildConfig.DEBUG为真时走文件系统,省掉大量重装时间。
验证方法上,我会按这张表逐项过一遍,而不是凭感觉判断“看起来对了”:
| 验证项 | 方法 | 不通过的典型表现 |
|---|---|---|
| 上下文可用 | glGetString(GL_VERSION)打印 | 返回 null,说明没 make current |
| 着色器链接 | 检查GL_LINK_STATUS与日志 | 属性句柄为 -1,某个通道不生效 |
| 纹理完整性 | 检查GL_TEXTURE_MIN_FILTER与 mipmap 是否匹配 | 纯黑或纯白 |
| 帧时间 | 记录相邻两帧swapBuffers间隔 | 抖动超过 5ms,说明有同步点 |
| 双目正确性 | 单眼渲染关掉一只眼对比 | 两只眼画面完全一致,说明偏移没生效 |
有一件事我在早期吃过不少亏:所有验证都只在手上那一台设备上做。图形代码的设备差异性比其他模块大得多,同一个片元着色器在三家驱动上可能有三种表现。所以现在每次改完渲染逻辑,我都会强制在至少两台不同芯片的设备上各跑一遍,先看画面再看帧率。这个习惯听起来笨,但比事后对着用户反馈猜原因要省事得多。希望帮到你。
本文还有配套的精品资源,点击获取