OpenGL ES Demo实战:EGL上下文、VR双目渲染与黑屏排查
简介OpenGLESDemo.zip 是一份面向 Android 平台图形开发者的 OpenGL ES 实战示例合集覆盖从基础渲染到虚拟现实显示的完整技能链路。包内共 1245 个文件压缩包约 12.63MB以 44 个 java 源文件与 6 个 glsl 着色器脚本为核心配合 class、dex、flat 等编译与构建产物以及 json、xml 配置png、jpg 贴图gradle 构建脚本完整保留了一套可直接对照的 Android 工程结构。示例内容围绕点、线、三角形绘制顶点与片段着色器编写纹理映射与 MIP 贴图视口与正交、透视投影环境光与镜面光模拟深度测试遮挡处理FBO 后期特效混合透明帧率控制动画展开并延伸到结合传感器数据的头部追踪、双目渲染与视差处理等 VR 场景。已有 249 人学习适合希望按主题逐个动手、打通图形管线各环节的中初级开发者作为参考工程。1. 拆开 OpenGLESDemo.zip 之前它到底能省掉你哪几天一个刚拿到 OpenGLESDemo.zip 的开发者最常见的状态是能跑通一句glClearColor把屏幕刷成红色但一旦要接真实的画面输入、要做双缓冲、要上 VR 双目分屏就开始在上下文创建和纹理格式上连续翻车。这份 OpenGL ES Demo 的价值不在“教程”而在于它把 EGL 环境搭建、着色器编译、VBO 绑定、FBO 离屏渲染这一整套骨架先串成了一条能跑的路你接手的是已经闭环的渲染链路而不是一堆散落的 API 片段。它适合三类人Android 或跨平台端刚接触原生 OpenGL ES、需要一份可对照的初始化模板的人手上有单目渲染代码、想扩到 VR 立体输出的团队以及被“黑屏但无报错”折磨过、想找一份能对照排查的参考实现的人。后面几章按“环境—管线—双目改造—排错—测量”的顺序往下拆每一步都落到能抄的参数和能复现的命令上。2. EGL 上下文与帧缓冲渲染链路的第一个可运行闭环GL 调用能不能跑起来取决于你有没有一块合法的上下文和一块尺寸正确的默认帧缓冲。很多 OpenGL ES 项目第一次崩就崩在这一层而不是着色器写错。2.1 为什么先啃 EGL 而不是直接抄 GL 调用OpenGL ES 本身不负责管理窗口、不负责创建绘制表面这些全部由 EGL 承担。GL 是一个状态机EGL 是给这个状态机供“电”的那一套配电系统。我从一份 Demo 里最先看的就是eglInitialize到eglMakeCurrent这一段因为它决定了后面所有代码的运行前提版本对不对、深度缓冲有没有、像素格式是否是 8-8-8-8。选型上有一个容易被忽略的点EGL 1.4 与 EGL 1.5 的差异不在于 API 数量而在于对EGL_KHR_create_context、EGL_ANDROID_*系列扩展的支持力度。移动端常见做法是先用EGL_OPENGL_ES2_BIT拿到一个能用的配置再通过EGL_KHR_create_context尝试升到 ES 3.x如果直接硬写EGL_OPENGL_ES3_BIT在某些老设备上eglChooseConfig会直接返回 0 个配置然后你在eglCreateContext那里拿到EGL_NO_CONTEXT一路空指针下去。我一般会写一个降级链ES3 → ES2两次都失败再报错而不是一次就断言。另一个选型点是窗口表面还是离屏表面。Demo 里如果同时存在EGL_WINDOW_BIT和EGL_PBUFFER_BIT说明它既支持上屏也支持后台渲染后者在截图、滤镜预处理、视频帧上色这类场景里更好用因为它不受窗口尺寸变化影响。2.2 上下文创建与窗口表面绑定的最小闭环下面这段是从 Demo 的初始化流程里抽出来的最小闭环可以直接对照你手上的代码看差在哪#include EGL/egl.h #include EGL/eglext.h #include GLES3/gl3.h static EGLDisplay g_display EGL_NO_DISPLAY; static EGLSurface g_surface EGL_NO_SURFACE; static EGLContext g_context EGL_NO_CONTEXT; bool egl_init(void *native_window, int width, int height) { // 1) 拿到默认显示连接 g_display eglGetDisplay(EGL_DEFAULT_DISPLAY); if (g_display EGL_NO_DISPLAY) return false; EGLint major 0, minor 0; if (!eglInitialize(g_display, major, minor)) return false; // 拿到 EGL 版本 // 2) 配置属性必须以 EGL_NONE 结尾否则行为未定义 const EGLint cfg_attr[] { EGL_SURFACE_TYPE, EGL_WINDOW_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, // 设备不支持时降级为 ES2_BIT EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_DEPTH_SIZE, 16, // VR 双目对深度精度更敏感 EGL_STENCIL_SIZE, 0, EGL_NONE }; EGLConfig config; EGLint num_config 0; if (!eglChooseConfig(g_display, cfg_attr, config, 1, num_config) || num_config 0) return false; // 3) 用属性复查一遍别信 chooseConfig 一定满足你的期望 EGLint depth_bits 0; eglGetConfigAttrib(g_display, config, EGL_DEPTH_SIZE, depth_bits); // 4) 创建上下文ES3 优先 const EGLint ctx_attr[] { EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE }; g_context eglCreateContext(g_display, config, EGL_NO_CONTEXT, ctx_attr); // 5) 创建窗口表面宽高为 0 时这一步会失败先判空 if (width 0 || height 0) return false; EGLNativeWindowType win (EGLNativeWindowType)native_window; g_surface eglCreateWindowSurface(g_display, config, win, nullptr); if (g_surface EGL_NO_SURFACE) return false; // 6) 绑定到当前线程GL 调用从此才有意义 if (!eglMakeCurrent(g_display, g_surface, g_surface, g_context)) return false; // 7) 交换间隔1 表示跟随垂直同步 eglSwapInterval(g_display, 1); return true; }逻辑说明整段代码的顺序不能调换——eglInitialize之后才能eglChooseConfig有了 config 才能建 context 和 surface最后eglMakeCurrent绑定到线程。参数说明上EGL_DEPTH_SIZE写 16 是移动端最稳的选择写 24 在部分设备上会拿不到配置eglSwapInterval(1)会把你的一帧锁在屏幕刷新周期上调试性能时先设 0 关掉垂直同步否则测出来的帧率永远是 60。有一个血泪经验渲染线程和 UI 线程是两个线程时GL 上下文只对调用eglMakeCurrent的那个线程有效。切线程前必须解绑// 渲染线程退出或切换前把上下文摘干净 eglMakeCurrent(g_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); // 资源释放顺序surface - context - terminate if (g_surface ! EGL_NO_SURFACE) eglDestroySurface(g_display, g_surface); if (g_context ! EGL_NO_CONTEXT) eglDestroyContext(g_display, g_context); eglTerminate(g_display);顺序写反了不会立刻崩但下次创建上下文时可能拿到脏状态这类问题最难查。2.3 帧缓冲与视口参数分辨率、像素比与黑边的账glViewport(0, 0, w, h)里的 w、h 是物理像素不是逻辑像素。这一条每年都能放倒一批人在高像素密度的机型上如果你把布局里的 dp 宽高直接传进来画面就会缩到左上角一小块右下角全黑而且 GL 不报任何错。正确的做法是从原生窗口层拿真实尺寸或者在拿到逻辑尺寸后乘以像素比// ANativeWindow 拿到的是物理像素直接用于 viewport int32_t buf_w ANativeWindow_getWidth(window); int32_t buf_h ANativeWindow_getHeight(window); glViewport(0, 0, buf_w, buf_h); // 每次 surface 尺寸变化后都要重设 // 若只有逻辑尺寸则显式换算 // float density 2.75f; // glViewport(0, 0, (int)(dp_w * density), (int)(dp_h * density));离屏渲染时FBO 的附件尺寸必须和 viewport 一致否则会出现“渲染完整但采样只取到左上角”的现象。ES3 下创建深度附件的常见写法GLuint fbo, color_tex, depth_rb; glGenFramebuffers(1, fbo); glBindFramebuffer(GL_FRAMEBUFFER, fbo); glGenTextures(1, color_tex); glBindTexture(GL_TEXTURE_2D, color_tex); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, color_tex, 0); glGenRenderbuffers(1, depth_rb); glBindRenderbuffer(GL_RENDERBUFFER, depth_rb); glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT16, w, h); glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, depth_rb); // 必须查状态不能假设成功 if (glCheckFramebufferStatus(GL_FRAMEBUFFER) ! GL_FRAMEBUFFER_COMPLETE) { // 常见原因尺寸为 0、格式不被支持、附件未被真正绑定 }参数上GL_RGBA8是 ES3 的内部格式ES2 里要写GL_RGBAGL_DEPTH_COMPONENT16够用且兼容性最好不要习惯性写 24。离屏目标一旦尺寸变化比如旋转屏幕必须重新glTexImage2D并重建 renderbuffer否则采样出来是拉伸的旧画面。2.4 一个可自检的初始化顺序表把上面几步压成一张检查表照着对一遍能过滤掉大部分“黑屏但没报错”步骤关键调用失败时的典型表现显示连接eglGetDisplay返回EGL_NO_DISPLAY后续全空初始化eglInitialize版本号拿不到配置查询全 0选配置eglChooseConfig配置数为 0通常是深度位或渲染类型写高了建上下文eglCreateContext返回空检查EGL_CONTEXT_CLIENT_VERSION建表面eglCreateWindowSurface宽高为 0 或窗口未就绪时失败绑定eglMakeCurrent绑定失败则所有 GL 调用被静默丢弃视口glViewport尺寸错则画面缩角或拉伸清屏glCleareglSwapBuffers没 swap 就永远看不到内容3. 着色器与顶点数据把 Demo 的固定管线思维换成可编程管线OpenGL ES 2.0 之后就没有固定管线了所有几何都得自己喂顶点、自己写着色器。Demo 里如果还残留glMatrixMode这类写法那基本是从更早的桌面 GL 代码搬过来的不能直接用。3.1 顶点属性布局与 VBO/VAO 绑定顺序顶点数据的组织方式决定了你后面加属性时会不会把内存布局搞乱。常见做法是交错存放position uv 打包在一个数组里而不是每个属性一个独立数组前者缓存命中率更高在一次 draw call 里只用一个 VBO 就够了。typedef struct { float x, y, z; // position float u, v; // texcoord } Vertex; static const Vertex kQuad[4] { {-1.f, -1.f, 0.f, 0.f, 1.f}, { 1.f, -1.f, 0.f, 1.f, 1.f}, {-1.f, 1.f, 0.f, 0.f, 0.f}, { 1.f, 1.f, 0.f, 1.f, 0.f}, }; static const GLushort kIdx[6] {0, 1, 2, 2, 1, 3}; GLuint vbo, ibo, vao; glGenVertexArrays(1, vao); // ES3 才有 VAOES2 需走扩展 glBindVertexArray(vao); glGenBuffers(1, vbo); glBindBuffer(GL_ARRAY_BUFFER, vbo); glBufferData(GL_ARRAY_BUFFER, sizeof(kQuad), kQuad, GL_STATIC_DRAW); glGenBuffers(1, ibo); glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo); glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(kIdx), kIdx, GL_STATIC_DRAW); // location 0 positionstride 用 sizeof(Vertex)offset 用 offsetof glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const void *)offsetof(Vertex, x)); glEnableVertexAttribArray(1); glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (const void *)offsetof(Vertex, u)); glBindVertexArray(0); // 解绑避免后续误改逻辑说明glVertexAttribPointer的第五个参数是步长必须等于整个顶点结构体大小写成 3 或 5 都会导致后面的属性错位、画面三角形乱飞。第六个参数是字节偏移用offsetof而不是手写常量结构体一改就不会错。glBindVertexArray(0)这一步很多人省掉结果在别处调用了glBindBuffer把 IBO 换掉下次绘制时索引全乱——这是玄学级 bug查起来能耗掉一整天。索引类型上顶点数少于 65536 用GL_UNSIGNED_SHORT超过就得用GL_UNSIGNED_INT而 ES2 默认不支持 32 位索引需要GL_OES_element_index_uint扩展ES3 才原生支持。VR 场景里每帧要画两个视图模型一复杂很容易破 65535这一点提前设计比事后改省事。3.2 顶点/片元着色器的最小可用组合ES3 的着色器头部必须写#version 300 es并且片元着色器必须声明精度。少了precision这行编译期就报错而不同驱动的报错信息差异很大容易误判。// vertex shader: 带 MVP 变换的最小版本 #version 300 es layout(location 0) in vec3 aPos; layout(location 1) in vec2 aUV; uniform mat4 uMVP; out vec2 vUV; void main() { vUV aUV; gl_Position uMVP * vec4(aPos, 1.0); }// fragment shader: 纹理采样 简单的亮度调节 #version 300 es precision mediump float; // 移动端 fragment 必须显式声明精度 in vec2 vUV; uniform sampler2D uTex; uniform float uExposure; out vec4 fragColor; void main() { vec4 c texture(uTex, vUV); fragColor vec4(c.rgb * uExposure, c.a); }编译与链接要带日志否则你只能看到一个GL_FALSEGLuint compile(GLenum type, const char *src) { GLuint s glCreateShader(type); glShaderSource(s, 1, src, nullptr); glCompileShader(s); GLint ok 0; glGetShaderiv(s, GL_COMPILE_STATUS, ok); if (!ok) { char log[1024] {0}; glGetShaderInfoLog(s, sizeof(log), nullptr, log); // 一定要打出来 glDeleteShader(s); return 0; } return s; }参数说明precision mediump float是速度与精度的折中做颜色运算够用做坐标或深度相关的运算要换highp否则在部分 GPU 上会出现精度抖动表现为画面轻微跳动——这也是很多人误以为是“矩阵算错了”的原因。layout(location n)在 ES3 里可用ES2 里只能靠glBindAttribLocation在glLinkProgram之前绑定两种方式的绑定时机不同混用必然错位。3.3 纹理采样与 YUV 输入的接法Demo 里如果输入是相机或视频帧大概率是 YUV 而非 RGB。这里有一个 ES2 到 ES3 的断层GL_LUMINANCE和GL_ALPHA在 ES3 里已被移除老代码直接搬过去会拿不到纹理。推荐做法是把 Y、U、V 三个平面分别上传成GL_R8纹理在片元着色器里做色彩转换// 上传单通道平面Y 平面宽高即画面尺寸 glPixelStorei(GL_UNPACK_ALIGNMENT, 1); // 行宽不是 4 的倍数时必须设 glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, w, h, 0, GL_RED, GL_UNSIGNED_BYTE, y_ptr);// 三平面上传后的 YUV - RGB 转换 #version 300 es precision mediump float; in vec2 vUV; uniform sampler2D uY, uU, uV; out vec4 fragColor; void main() { float y texture(uY, vUV).r; float u texture(uU, vUV).r - 0.5; float v texture(uV, vUV).r - 0.5; // BT.601 有限范围系数 float r y 1.402 * v; float g y - 0.344 * u - 0.714 * v; float b y 1.772 * u; fragColor vec4(r, g, b, 1.0); }glPixelStorei(GL_UNPACK_ALIGNMENT, 1)这行是纯血泪教训默认对齐是 4当画面宽度不是 4 的倍数时每一行都会错位几个字节最终画面出现斜向条纹。现象看起来像“纹理采样坐标错了”实际是内存对齐的问题调半天着色器也白搭。纹理参数上GL_CLAMP_TO_EDGE必须设否则边缘采样到边框色缩小过滤要么设GL_LINEAR要么完整生成 mipmap只设GL_LINEAR_MIPMAP_LINEAR却不调glGenerateMipmap画面会全黑——这个坑在 ES 里非常经典。3.4 uniform 与 attribute 的绑定策略每个着色器程序都要缓存 uniform location不要在每帧绘制时调glGetUniformLocation那是一个字符串查找开销远比你想的大typedef struct { GLuint prog; GLint uMVP, uTex, uExposure; } Program; void program_init(Program *p) { p-uMVP glGetUniformLocation(p-prog, uMVP); p-uTex glGetUniformLocation(p-prog, uTex); p-uExposure glGetUniformLocation(p-prog, uExposure); } void program_use(Program *p) { glUseProgram(p-prog); glUniform1i(p-uTex, 0); // 纹理单元 0与 glActiveTexture 对应 glUniform1f(p-uExposure, 1.0f); }注意glUniform1i(p-uTex, 0)这个 0 是纹理单元编号不是纹理 ID。写成纹理 ID 在某些设备上“碰巧能用”因为 ID 从 1 开始时会退回默认单元换个驱动就错。我一般会在初始化时统一把所有采样器赋成固定的单元号运行时只调glActiveTexture(GL_TEXTURE0 n)和glBindTexture。4. 单目到 VR 双目立体渲染改造的四个动作把一份单目 OpenGL ES Demo 改成 VR 输出难点不在“画两遍”而在于相机参数、视口切分和时延预算三件事必须同时对上缺一个就会出现“能看但看久了头晕”。4.1 相机矩阵与 IPD把左右眼偏移算对双目渲染的本质是两次视角略有差异的投影。左右眼相机在世界空间里沿 X 轴各偏移半个瞳距IPD成年人常用值 63mm 左右可按用户设置微调。这里要注意单位统一如果你的场景单位是米IPD 就要写成 0.063单位是厘米就写 6.3写错量级的结果是“左右眼画面几乎一样”或者“完全对不上”。// 简化的透视投影fov_y 为竖直视场角弧度 void mat4_perspective(float *m, float fov_y, float aspect, float n, float f) { float t 1.0f / tanf(fov_y * 0.5f); memset(m, 0, sizeof(float) * 16); m[0] t / aspect; m[5] t; m[10] (f n) / (n - f); m[11] -1.0f; m[14] (2.0f * f * n) / (n - f); } // 左右眼视图矩阵沿相机右向量平移半个 IPD void build_eye_view(float *dst, const float *head_view, float half_ipd, int is_right) { memcpy(dst, head_view, sizeof(float) * 16); // 假设 head_view 的右向量在第 0 列 float sign is_right ? 1.0f : -1.0f; dst[12] head_view[0] * half_ipd * sign; dst[13] head_view[1] * half_ipd * sign; dst[14] head_view[2] * half_ipd * sign; }参数说明half_ipd是半个瞳距左右各一次fov_y一般取 90° 左右更接近头显体验但移动端受限于 GPU 填充率实际常用 80°100° 之间。aspect在双目模式下是单眼视口的宽高比不是整屏宽高比写成整屏会让画面横向拉伸一倍——这是最常见的翻车点。另外VR 里最好用非对称视锥frustum因为人眼相对镜片的中心并不重合对称透视会让人产生轻微的重影感。Demo 如果只给了对称版本可以先跑通再替换不必一上来就追求精确。4.2 分屏视口与畸变预矫正分屏是最直观的一步把整块 framebuffer 左右切一半左眼画左边右眼画右边。int w surface_width, h surface_height; // 左眼 glViewport(0, 0, w / 2, h); glScissor(0, 0, w / 2, h); glEnable(GL_SCISSOR_TEST); draw_scene(left_eye); // 右眼 glViewport(w / 2, 0, w / 2, h); glScissor(w / 2, 0, w / 2, h); draw_scene(right_eye); glDisable(GL_SCISSOR_TEST);glViewport和glScissor都要设只设 viewport 在某些驱动上仍会清掉整屏导致另一只眼的画面被本帧的glClear抹掉。清屏要放在两次绘制之前统一做一次或者用 scissor 限制清屏范围。畸变预矫正则是把镜片的桶形畸变提前反向压进画面。常见做法是在片元着色器里做反向映射而不是后处理一遍整屏// 反向桶形畸变给定输出像素去源图里取对应位置 #version 300 es precision highp float; in vec2 vUV; uniform sampler2D uTex; uniform vec2 uLensCenter; // 单眼画面的归一化中心如 (0.5, 0.5) uniform vec2 uLensK; // (k1, k2)由镜片实测拟合 uniform vec2 uScale; // 预缩放避免边缘出现黑边 out vec4 fragColor; void main() { vec2 theta vUV - uLensCenter; float r2 dot(theta, theta); vec2 warped theta * (1.0 uLensK.x * r2 uLensK.y * r2 * r2); vec2 uv uLensCenter warped * uScale; // 超出边界时取边缘色避免出现黑框 uv clamp(uv, 0.0, 1.0); fragColor texture(uTex, uv); }uLensK的两个系数来自镜片实测没有通用值调参顺序是先把 k1 从 0 慢慢加到画面直线变直再动 k2 修边缘最后用uScale放大一点点把黑边推出可视区。这个过程没有解析解靠试。4.3 帧循环改写从一次绘制到两次提交单目代码通常长这样清屏 → 设一次矩阵 → 画一次 → swap。双目改造后帧循环要变成void render_frame(void) { glBindFramebuffer(GL_FRAMEBUFFER, 0); glClearColor(0.f, 0.f, 0.f, 1.f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); float view[16], proj[16], mvp[16]; mat4_perspective(proj, FOV_Y, (float)(w / 2) / (float)h, 0.05f, 100.f); for (int eye 0; eye 2; eye) { glViewport(eye * (w / 2), 0, w / 2, h); build_eye_view(view, head_view, kHalfIpd, eye); mat4_mul(mvp, proj, view); glUniformMatrix4fv(g_prog.uMVP, 1, GL_FALSE, mvp); draw_scene(); } eglSwapBuffers(g_display, g_surface); }关键点是每次循环都要重新设置 viewport 和 MVP不要指望上一轮的状态还在——GL 是状态机但你中途调用的其他绘制函数可能已经改掉了它。我见过最隐蔽的一次错位是某段后处理代码在左眼绘制后偷偷把 viewport 改回整屏导致右眼画面只画了左半边。4.4 移动端 VR 的时延预算VR 的舒适度和帧率、延迟强相关。移动端一份常见的预算是环节目标耗时超支后的表现传感器采样与预测13 ms转头时画面滞后应用逻辑与场景更新36 ms掉帧、卡顿双眼绘制提交812 ms帧率跌到 60 以下合成与显示扫描输出24 ms整体延迟累积把总时间压在 16.6ms 以内才能稳 60 帧压到 11ms 左右才有余量做重投影。这份 Demo 如果只做了分屏没做时间扭曲timewarp转头部时一定会有拖影这属于架构层面的缺失不是调参能救的。工程上的止损办法是降低单眼分辨率、关掉不必要的后处理、把畸变矫正从多 pass 合成一次。5. 排查与避坑六类高频翻车现场的现象、原因与解法下面这些是我在实际对接这份 Demo 时反复遇到的每条都按“看到什么 → 为什么 → 怎么修”写照着对号入座基本能定位。5.1 画面全黑但没有任何 GL 错误现象eglSwapBuffers正常返回glGetError一直是GL_NO_ERROR屏幕就是黑的。原因通常有三层。第一层是上下文没绑到当前线程比如渲染跑在子线程但eglMakeCurrent在主线程调的此时所有 GL 调用被静默丢弃错误码自然也是干净的。第二层是 surface 尺寸为 0比如窗口还没 attach 就开始渲染viewport 变成 0×0什么都画不出来。第三层是清屏颜色本身是黑的而绘制又因为剔除或深度测试被全部丢弃。解决在初始化后立刻打一行GL_VERSION、GL_RENDERER和 viewport 尺寸的日志三秒就能排除前两层。若日志正常再把glDisable(GL_DEPTH_TEST)、glDisable(GL_CULL_FACE)临时打开看看有没有东西出来用于区分“没画”和“画了但被丢弃”。5.2 纹理上下颠倒或左右翻转现象贴图能显示但方向反了或者左右镜像。原因OpenGL 的纹理坐标原点在左下角而多数图像解码库位图、视频帧给的数据是从左上角开始逐行排列的。你不翻转画面就是倒的。另一个来源是glTexImage2D之后调了glPixelStorei(GL_UNPACK_FLIP_Y_WEBGL, ...)之类的非标准接口行为随驱动变化。解决二选一要么在顶点数据里把 V 坐标翻过来把 uv 的 v 写成 1.0 - v要么在着色器里texture(uTex, vec2(vUV.x, 1.0 - vUV.y))。不要在 CPU 侧逐帧翻转像素那会多一次全画面拷贝移动端上很贵。左右镜像则多半是投影矩阵的 X 轴符号写反检查m[0]的符号即可。5.3 高分辨率机型上画面只占左上角现象低端机正常高分屏上画面缩成一小块右下角全黑。原因viewport 用了逻辑像素而不是物理像素。演示代码里写死的 1080×1920 在 2K 屏上只覆盖了左上四分之一。解决改用窗口层返回的物理尺寸或者在拿到逻辑尺寸后乘以密度比。同时注意glViewport要在每次 surface 尺寸变化旋转、分屏、折叠屏展开后重新调用不能只在初始化时设一次。这条我在折叠屏设备上踩得最深展开后画面直接缩成一条。5.4 运行几分钟后掉帧、机身发烫现象冷启动 60 帧跑三五分钟后掉到 30 帧以下温度明显上升。原因每帧重建 FBO、纹理或着色器程序。有的人图省事在onDrawFrame里调glGenTexturesglTexImage2DGPU 显存不断分配释放驱动侧的垃圾回收压力全压在你身上。另一个来源是每帧调glFinish把 CPU 和 GPU 强行同步管线彻底失去并行。解决所有 GL 资源在初始化阶段创建一次尺寸变化时只重建受影响的那些用glFlush或干脆不同步靠双缓冲自然流水把每帧的glGetError改成调试期开关正式包里关掉这个调用本身会打断管线。5.5 双目画面撕裂或左右眼内容错位现象左右两半画面内容不一致或者中间有一条明显的接缝。原因两次绘制之间没有恢复状态。常见的是一次绘制改了glViewport、解绑了 VAO、或者改了 uniform 却没改回来另一种是只清了一次屏但清屏发生在左眼绘制之后把左眼内容抹掉了一半。解决把每只眼的绘制包成一个函数进入时设置全部状态viewport、scissor、program、uniform、VAO、纹理单元退出时不依赖任何残留状态。清屏统一放在循环外做一次。如果接缝处颜色不一致检查 scissor 边界是不是差了 1 像素w/2遇到奇数宽时会有一列像素归属不明。5.6 纹理出现斜向条纹或花屏现象画面能出来但布满规律性的斜纹或彩色噪点。原因glPixelStorei(GL_UNPACK_ALIGNMENT, 1)没设。默认对齐是 4 字节当图像行宽不是 4 的倍数时每一行的起始位置都会偏移累积起来就是条纹。另一个原因是纹理格式和上传格式不匹配比如用GL_RGBA上传单通道数据。解决凡是单通道或行宽不确定的数据上传前一律glPixelStorei(GL_UNPACK_ALIGNMENT, 1)上传后可以还原成 4 以免影响其他路径。格式上单通道用GL_R8GL_RED双通道用GL_RG8GL_RG别混。6. 进阶用 GPU 计时和回归清单把这份 Demo 变成自己的基线把 Demo 跑通只是起点真正让它变成可复用资产的动作是给每一帧加上可测量的耗时数据再固化成一份回归清单。移动端最容易被误判的是“CPU 侧很快但画面就是卡”这种情况只有 GPU 计时能说清。如果设备支持GL_EXT_disjoint_timer_query可以这样拿到单次绘制的 GPU 耗时GLuint q; glGenQueriesEXT(1, q); glBeginQueryEXT(GL_TIME_ELAPSED_EXT, q); draw_scene(); // 被测的绘制段 glEndQueryEXT(GL_TIME_ELAPSED_EXT); GLint ready 0; while (!ready) { // 结果不是立刻可取的需轮询 glGetQueryObjectivEXT(q, GL_QUERY_RESULT_AVAILABLE_EXT, ready); } GLuint64 ns 0; glGetQueryObjectui64vEXT(q, GL_QUERY_RESULT_EXT, ns); // 注意检查 GL_GPU_DISJOINT_EXT为真时这次结果不可信 GLint disjoint 0; glGetIntegerv(GL_GPU_DISJOINT_EXT, disjoint);这段的价值在于它把“帧率低”拆成了“哪一段低”。我一般会在三个位置各插一个 query清屏之后到左眼绘制结束、左眼到右眼、右眼到 swap 之前三段对比就能看出是几何量太大还是填充率被打爆。注意轮询不要写成死循环阻塞渲染线程正式实现里应该把 query 挂到帧队列上下一帧再取上一帧的结果。回归清单我固定这几项每次改动渲染路径后跑一遍检查项方法通过标准上下文版本打印GL_VERSION/GL_RENDERER与目标机型一致无降级帧缓冲完整性glCheckFramebufferStatus返回FRAMEBUFFER_COMPLETE单帧 GPU 耗时timer query双眼合计不超过预算的 70%纹理方向用带方向标记的测试图上下左右均正确旋转/折叠后视口改变 surface 尺寸画面无拉伸、无缩角连续运行 10 分钟观察帧率曲线无持续下滑这套东西做完之后你手上就不只是一份能跑的 OpenGL ES Demo而是一个带基线数据的渲染起点下次换设备、换分辨率、加后处理都有对照。我自己有段时间图快改完直接上真机看结果在一台设备上帧率正常、另一台直接掉一半查了两天才发现是纹理尺寸不是 2 的幂导致走了软件回退路径。从那以后我每次动渲染路径都强制先把 timer query 和这张清单走一遍不看画面先看数字。希望帮到你。本文还有配套的精品资源点击获取