ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenGL ES Demo实战:EGL上下文、VR双目渲染与黑屏排查

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_RGBA;GL_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尺寸错则画面缩角或拉伸
清屏glClear+eglSwapBuffers没 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 才有 VAO,ES2 需走扩展 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 = position,stride 用 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_FALSE:

GLuint 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 的舒适度和帧率、延迟强相关。移动端一份常见的预算是:

环节目标耗时超支后的表现
传感器采样与预测1~3 ms转头时画面滞后
应用逻辑与场景更新3~6 ms掉帧、卡顿
双眼绘制提交8~12 ms帧率跌到 60 以下
合成与显示扫描输出2~4 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里调glGenTextures+glTexImage2D,GPU 显存不断分配释放,驱动侧的垃圾回收压力全压在你身上。另一个来源是每帧调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_R8+GL_RED,双通道用GL_RG8+GL_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 和这张清单走一遍,不看画面先看数字。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进