☰
OpenGL ES渲染管线实战:从EGL到VR双目立体渲染
2026/10/26 3:38:49 网站建设 项目流程

简介:这份资源是面向 Android 平台图形开发初学者与进阶者的 OpenGL ES 实战示例合集,围绕移动端 2D/3D 渲染、动态视觉效果及 VR 场景构建展开,帮助开发者解决从理论概念到可运行代码之间的落地问题。压缩包共 1245 个文件,约 12.63MB,以 flat、dex、class 等 Android 构建与编译产物为主,配有 java 源码、xml 布局配置、glsl 着色器脚本、json 与 properties 配置文件及 png、jpg 贴图素材,并附带 gradle 构建脚本与 apk 安装包,可直接对照运行与调试。已有 249 人学习关注。内容覆盖基础几何图形渲染、顶点与片段着色器编写、纹理映射与 MIP 贴图、视口与投影变换、光照阴影、深度测试、帧缓冲对象与后期处理、混合模式、动画帧率控制,以及结合传感器数据的双目立体渲染等 VR 相关要点;每个子模块对应一个独立主题,便于按知识点逐个拆解练习,是理解 OpenGL ES 渲染管线与在 Android 上搭建复杂图形应用的实用参考。

1. 从一个黑屏说起:OpenGLESDemo 里到底有什么

第一次解压 OpenGLESDemo.zip 跑起来,画面全黑、日志干净得像什么都没发生——这种体验在 OpenGL 项目里太常见了。渲染出错不会抛异常,它只是安静地把东西画到屏幕外,所以你连从哪查都不知道。这个 Demo 的价值恰恰在这儿:它把 OpenGL ES 从 EGL 上下文创建、着色器编译、纹理上传,一路到 VR 双目立体渲染的整条链路,用足够小的代码量串成了一条能跑通的主线。适合两类人:一类是刚啃完三角形教程、想看到「三角形之外完整流程」的开发者;另一类是手上有个普通 3D 预览功能,想加一层 VR 双眼模式,但不想为此塞进一整套渲染引擎的人。全文按「这份资源能干什么 → 怎么跑起来 → 怎么改」的顺序展开,尽量把每个环节的参数和失败点都写清楚。

2. 目录结构与渲染管线:从 EGL 上下文到第一帧

2.1 目录分层:平台层、渲染层、资源层三块

解压之后先别急着点编译,花五分钟看目录。渲染类 Demo 最容易烂在「所有代码塞一个文件」上,200 行之后谁都不敢动。这个包的组织方式相对干净,大致按平台壳、渲染核心、资源三类分开,常见做法是这样:

目录 / 文件职责改这里会怎样
app/或src/main/java平台壳:Activity、GLSurfaceView、权限、触摸事件换平台时整块重写
src/main/cpp/render/渲染核心:上下文、着色器、绘制90% 的改动都落在这里
src/main/cpp/math/向量、4x4 矩阵、四元数换成 glm 也没问题
src/main/assets/shaders/GLSL 源码,运行时读取或预编译调效果的主要入口
src/main/assets/textures/贴图、立方体贴图换素材
CMakeLists.txt原生库编译、链接 GLESv3、EGL、log加依赖时必改

分层带来的好处很直接:平台层只负责「给我一块能画的 Surface」,渲染层拿到 Surface 之后完全不关心它是 Android 的 ANativeWindow 还是别的什么。我第一次把它往别的平台搬,只重写了平台壳那两百来行,渲染层的文件一个没动。

对应的构建脚本片段大概长这样:

cmake_minimum_required(VERSION 3.18) project(glesdemo CXX) add_library(glesdemo SHARED src/main/cpp/render/egl_core.cpp src/main/cpp/render/shader_program.cpp src/main/cpp/render/mesh.cpp src/main/cpp/render/stereo_renderer.cpp src/main/cpp/math/mat4.cpp ) # GLESv3 负责绘制,EGL 负责拿到上下文,log 用来输出 GL 错误 find_library(EGL_LIB egl) find_library(GLES_LIB GLESv3) find_library(LOG_LIB log) target_link_libraries(glesdemo ${EGL_LIB} ${GLES_LIB} ${LOG_LIB})

add_library(... SHARED)决定产物是动态库,平台层通过 JNI 调进来;find_library(GLES_LIB GLESv3)这一行是关键,写成GLESv2就只能用 ES 2.0 的 API,后面所有#version 300 es的着色器都会编译失败。链接不到GLESv3时先确认 NDK 版本里有没有对应的 stub,不要急着怀疑代码。

2.2 EGL 初始化:上下文、Surface 和首帧

真正干活之前,必须先把「在哪画」(Surface)、「用什么画」(Context)、「画到哪块配置」(Config)三件事凑齐。这块代码短,但每一步失败都会导致后面的全黑,所以我会加足够多的返回值检查。

// 初始化 EGL:拿到 display、选 config、建 context、绑定 surface bool EglCore::init(EGLNativeWindowType window) { display_ = eglGetDisplay(EGL_DEFAULT_DISPLAY); EGLint major = 0, minor = 0; if (!eglInitialize(display_, &major, &minor)) { return false; // 驱动或环境异常,基本救不回来 } // 优先要 ES 3.0 的渲染位,深度 16 位够用,模板先不要 const EGLint cfgAttribs[] = { EGL_SURFACE_TYPE, EGL_WINDOW_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_ES3_BIT, EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, EGL_DEPTH_SIZE, 16, EGL_NONE }; EGLConfig config = nullptr; EGLint numConfigs = 0; if (!eglChooseConfig(display_, cfgAttribs, &config, 1, &numConfigs) || numConfigs < 1) { // 有些老设备没有 ES3 配置,这里要回退到 EGL_OPENGL_ES2_BIT return false; } // 创建上下文,声明客户端版本为 3 const EGLint ctxAttribs[] = { EGL_CONTEXT_CLIENT_VERSION, 3, EGL_NONE }; context_ = eglCreateContext(display_, config, EGL_NO_CONTEXT, ctxAttribs); // 创建窗口 Surface 并绑定上下文 surface_ = eglCreateWindowSurface(display_, config, window, nullptr); if (!eglMakeCurrent(display_, surface_, surface_, context_)) { return false; } // 1 = 跟随垂直同步,能压住无意义的空转,也能避免撕裂 eglSwapInterval(display_, 1); return true; }

几个参数值得单独说。EGL_CONTEXT_CLIENT_VERSION填 3,着色器里才能用#version 300 es、in/out、VAO 这些东西;填 2 的话后面所有新语法全部报编译错误。EGL_DEPTH_SIZE填 16 而不是 24,是为了在低端设备上更容易选到配置,如果场景里有大量重叠几何,深度精度不够会出现 z-fighting,那就得加回到 24。eglSwapInterval(display_, 1)打开垂直同步,代价是帧率被钉在 60,好处是不会出现帧撕裂,也不会有一帧里画两次的玄学现象。

每帧结束时必须eglSwapBuffers(display_, surface_),忘了这一句的表现是:代码全程无报错,画面纹丝不动。这是我在调试阶段踩过的坑,查了一下午才发现是漏了交换缓冲。

2.3 着色器编译与 VAO/VBO 的组织

着色器是这套流程里唯一「写错了会明确告诉你」的部分——编译失败能拿到日志,比纯黑屏好查太多。所以我会把编译、链接、拿日志三段包成一个函数,任何一次调用都打日志。

// 编译单个着色器:失败时把 infoLog 完整打出来,别只看返回码 GLuint compileShader(GLenum type, const char* src) { GLuint shader = glCreateShader(type); glShaderSource(shader, 1, &src, nullptr); glCompileShader(shader); GLint ok = GL_FALSE; glGetShaderiv(shader, GL_COMPILE_STATUS, &ok); if (!ok) { GLint len = 0; glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &len); std::string log(len, '\0'); glGetShaderInfoLog(shader, len, nullptr, log.data()); LOGE("shader compile failed: %s", log.c_str()); glDeleteShader(shader); return 0; } return shader; }

链接阶段同理,用glGetProgramiv(program, GL_LINK_STATUS, &ok)判断,失败读glGetProgramInfoLog。真正有效的日志是在glLinkProgram之后读的,只读编译日志会漏掉 varying 不匹配这类问题。

顶点数据组织上,这个 Demo 用的是交错布局:位置vec3加 UVvec2,一个顶点 20 字节,一次glBufferData传完、一次glVertexAttribPointer配完。相比位置和 UV 各开一个 VBO,交错布局的缓存命中率更好,代码也更短:

// 顶点属性布局:location 0 = 位置(vec3),location 1 = UV(vec2) struct Vertex { float x, y, z, u, v; }; glGenVertexArrays(1, &vao_); glBindVertexArray(vao_); glGenBuffers(1, &vbo_); glBindBuffer(GL_ARRAY_BUFFER, vbo_); glBufferData(GL_ARRAY_BUFFER, sizeof(Vertex) * verts.size(), verts.data(), GL_STATIC_DRAW); // stride = 5 * sizeof(float) = 20 字节,偏移 0 和 12 字节 glEnableVertexAttribArray(0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0); glEnableVertexAttribArray(1); glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)(sizeof(float) * 3));

这里有个容易翻车的点:VAO 是 ES 3.0 才进核心的,ES 2.0 上要靠扩展。如果为了兼容老设备把上下文降到 2.0,这几行glGenVertexArrays会静默失效,或者直接崩在空函数指针上。要么统一用 ES 3.0,要么在 2.0 路径上老老实实每次绘制都重新绑 VBO 和属性指针。GL_STATIC_DRAW表示数据几乎不改,如果顶点每帧都变(比如粒子),换成GL_DYNAMIC_DRAW性能会好一些。

3. 核心模块拆解:纹理、矩阵与相机控制

3.1 纹理加载与采样参数

大部分「渲染不对」的锅其实不在着色器,而在纹理上传这一步。图片解码出来是 RGB 三通道,OpenGL 默认按 4 字节对齐读取,宽不是 4 的倍数时就会错位,画面表现为斜着的彩色条纹,很容易被误判成着色器 bug。

// 上传纹理:先解对齐,再决定是否生成 mipmap GLuint loadTexture(const uint8_t* pixels, int w, int h, int channels) { GLuint tex = 0; glGenTextures(1, &tex); glBindTexture(GL_TEXTURE_2D, tex); // 关键:RGB 三通道在宽度非 4 倍数时会被按 4 字节对齐读取 glPixelStorei(GL_UNPACK_ALIGNMENT, 1); GLenum fmt = (channels == 4) ? GL_RGBA : GL_RGB; glTexImage2D(GL_TEXTURE_2D, 0, fmt, w, h, 0, fmt, GL_UNSIGNED_BYTE, pixels); // 缩小用 mipmap 三线性,放大用线性,避免摩尔纹和锯齿 glGenerateMipmap(GL_TEXTURE_2D); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); // 平铺模式,配合 UV 超范围时使用 glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); return tex; }

GL_UNPACK_ALIGNMENT设成 1 之后,三通道图片就能按实际字节数逐行读取。GL_LINEAR_MIPMAP_LINEAR需要glGenerateMipmap已经执行过,否则纹理是不完整的,采样结果全黑——这是另一个高频黑屏来源,尤其当你在别处把MIN_FILTER改成 mipmap 模式却忘了生成 mipmap 时。宽高最好都取 2 的幂,虽然 ES 3.0 已经放宽了限制,但老设备上一旦用了非 2 次幂纹理,GL_REPEAT会退化,画面边缘会出现一条突兀的拉伸。

3.2 矩阵栈:MVP 变换到底怎么算

3D 场景里物体看不见,八成是矩阵乘错或者乘反了。这个 Demo 里用手写的 4x4 矩阵,列主序存储,和 OpenGL 的约定一致。核心就三个矩阵:模型(Model)、视图(View)、投影(Projection),最终传给着色器的是P * V * M。

// 透视投影:fovY 单位是弧度,near/far 决定深度精度分布 Mat4 perspective(float fovY, float aspect, float zNear, float zFar) { float f = 1.0f / std::tan(fovY * 0.5f); Mat4 m{}; m[0] = f / aspect; // 横向缩放,aspect = 宽 / 高 m[5] = f; // 纵向缩放 m[10] = (zFar + zNear) / (zNear - zFar); m[11] = -1.0f; // 透视除法,w = -z m[14] = (2 * zFar * zNear) / (zNear - zFar); return m; } // 平移到相机后方并稍微旋转,避免物体正好在近裁剪面上 Mat4 model = translate(0.0f, 0.0f, -3.0f) * rotateY(yaw_) * rotateX(pitch_); Mat4 view = lookAt(eye_, center_, up_); Mat4 mvp = projection * view * model; glUniformMatrix4fv(uMvp_, 1, GL_FALSE, mvp.data());

glUniformMatrix4fv第三个参数GL_FALSE表示不转置,因为内存里已经是列主序,这正是 OpenGL 要的格式。如果从别的数学库(行主序)搬矩阵过来,这里必须传GL_TRUE,否则整个矩阵会像转置一样乱掉,表现是物体被拉伸到奇怪的方向。zNear别设太小,0.0001 这种值会把深度精度全耗在近距离,远处几何开始闪烁;一般 0.1 / 100 或者 0.3 / 1000 就够用。

3.3 输入处理与帧循环:让相机真的能转

有了矩阵,还得有数据驱动它。触摸和陀螺仪两条输入最终都归一化成「yaw / pitch 两个角度」,交给模型矩阵。帧循环则是一个标准的onDrawFrame:清屏、更新状态、绘制、交换缓冲。

// 每帧入口:顺序别乱,先 clear 再 draw 最后 swap void Renderer::onDrawFrame() { glViewport(0, 0, width_, height_); glClearColor(0.05f, 0.05f, 0.08f, 1.0f); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 触摸滑动换算成角度,clamp(±89°) 防止万向锁附近的抖动 yaw_ += touchDeltaX_ * 0.15f; pitch_ = clamp(pitch_ + touchDeltaY_ * 0.15f, -89.0f, 89.0f); touchDeltaX_ = touchDeltaY_ = 0.0f; updateMvp(); glUseProgram(program_); glBindVertexArray(vao_); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, texture_); glUniform1i(uTex_, 0); glDrawElements(GL_TRIANGLES, indexCount_, GL_UNSIGNED_SHORT, nullptr); eglSwapBuffers(display_, surface_); }

glClear里带GL_DEPTH_BUFFER_BIT这一位很关键,忘了清深度会导致上一帧的遮挡关系残留,画面看起来像有幽灵残影。glActiveTexture(GL_TEXTURE0)和glUniform1i(uTex_, 0)必须成对出现,只绑纹理不设 sampler,采样器默认指向 0 号单元,在某些驱动上能用、某些驱动上全黑,属于典型的跨设备玄学。触摸增量用完立刻清零,不然相机会一直朝一个方向飞出去。

4. VR 双目光路改造:从单目到立体渲染

4.1 双目视口与非对称投影

VR 模式的本质不复杂:同一个场景画两遍,左右各一个视口,两眼的相机位置水平错开一个瞳距(成年人平均 64mm 左右),投影矩阵稍微向内偏,形成会聚。视口用glViewport切成左右两半,每次绘制前重新设置。

// 双目渲染:两次 viewport + 两次 MVP,共用同一份几何和纹理 void StereoRenderer::drawFrame() { const float ipd = 0.064f; // 瞳距,单位米 const int w = width_ / 2, h = height_; for (int eye = 0; eye < 2; ++eye) { // 左眼占左半屏,右眼占右半屏 glViewport(eye * w, 0, w, h); // 相机沿 X 轴左右各偏移半个瞳距 float offset = (eye == 0) ? -ipd * 0.5f : ipd * 0.5f; Mat4 eyeView = translate(offset, 0.0f, 0.0f) * baseView_; // 非对称视锥:水平方向略微偏移,模拟两眼会聚 Mat4 proj = frustumOffset(fovY_, (float)w / h, 0.1f, 100.0f, eye); Mat4 mvp = proj * eyeView * model_; glUniformMatrix4fv(uMvp_, 1, GL_FALSE, mvp.data()); glDrawElements(GL_TRIANGLES, indexCount_, GL_UNSIGNED_SHORT, nullptr); } eglSwapBuffers(display_, surface_); }

glViewport(eye * w, 0, w, h)的四个参数依次是左下角 x、y、宽、高,坐标原点在屏幕左下角,所以右眼要从w开始而不是 0。每只眼的宽高比必须用「半屏宽 / 屏高」,不能沿用整屏的宽高比,否则画面会被横向压扁——这是双目模式最典型的翻车现场。frustumOffset里把 left / right 不对称地偏一点就能得到会聚效果,比起旋转相机,非对称视锥不会引入额外的梯形畸变。

4.2 桶形畸变与色差校正

透镜式的 VR 盒子会带来桶形畸变,直边看起来往外鼓。软件侧的做法是「预畸变」:在片段着色器里反向把采样坐标往里收,透镜再鼓一次,正好抵消。这段是纯 shader 逻辑,改起来比改 C++ 快得多。

// 片段着色器:按半径平方反向收缩采样坐标,抵消透镜桶形畸变 #version 300 es precision mediump float; in vec2 vUv; out vec4 fragColor; uniform sampler2D uTex; // 三个分量分别对应 R/G/B 的畸变系数,制造轻微色差 const vec3 kDistort = vec3(0.22, 0.24, 0.26); void main() { // 把 UV 从 [0,1] 映射到 [-1,1],以屏幕中心为原点 vec2 p = vUv * 2.0 - 1.0; float r2 = dot(p, p); // 半径平方 vec3 offset = p.xyx * r2 * kDistort; // 每个通道的偏移量 vec2 rUv = vec2((p.x + offset.x) * 0.5 + 0.5, (p.y + offset.y) * 0.5 + 0.5); vec2 gUv = vec2((p.x + offset.y) * 0.5 + 0.5, (p.y + offset.y) * 0.5 + 0.5); vec2 bUv = vec2((p.x + offset.z) * 0.5 + 0.5, (p.y + offset.z) * 0.5 + 0.5); fragColor = vec4(texture(uTex, rUv).r, texture(uTex, gUv).g, texture(uTex, bUv).b, 1.0); }

kDistort三个系数不一样,红绿蓝采样位置就会错开一点点,边缘出现彩色描边,这就是所谓色差,视觉上更像真实透镜。系数不能调太大,超过 0.4 之后边缘会被采样到纹理之外,出现一圈黑边。precision mediump float在大多数移动 GPU 上够用,但如果畸变系数很大,r2的精度不够会导致同心圆状的色带,换成highp能缓解。采样次数从 1 次变成 3 次,性能开销上去了,所以畸变区域最好限制在可见范围内。

4.3 姿态数据接入与重投影

VR 里最影响体感的是延迟,头动了画面要跟着动。常规链路是:传感器出四元数 → 转成视图矩阵 → 绘制。中间任何一环慢了,用户就会觉得画面「拖」。Demo 里为了简单,直接把最新姿态塞进baseView_,不做预测。

// 传感器回调里只存最新值,不改矩阵,避免和渲染线程打架 void onRotationChanged(float qx, float qy, float qz, float qw) { std::lock_guard<std::mutex> lk(poseMutex_); latestPose_ = Quat{qx, qy, qz, qw}; } // 渲染线程每帧取一次快照,转成视图矩阵 Mat4 Renderer::consumeViewMatrix() { Quat q; { std::lock_guard<std::mutex> lk(poseMutex_); q = latestPose_; } // 头部姿态取逆,因为视图矩阵是「世界到相机」 return mat4FromQuat(conjugate(q)); }

传感器回调和渲染线程必须隔离,直接在回调里改矩阵会导致某一帧拿到写了一半的数据,表现为画面偶尔抽搐一下。姿态取逆这一步也别省,四元数表示的是「设备到世界」的旋转,视图矩阵要的是反过来,忘了取共轭的表现是头往右转、画面往左跑。真要做预测,常见做法是根据最近几帧的角速度外推 10~20ms,但这需要时间戳对齐,Demo 层面不合适上。

5. 避坑与排查:编译能过但画面不对的那些事

5.1 全黑画面,日志里什么都没有

现象:应用正常启动,界面有响应,但渲染区域纯黑,logcat里没有 GL 错误。

原因通常是三类:eglSwapBuffers没调用,画面根本没提交;纹理没生成 mipmap 但采样器用了*_MIPMAP_*,纹理不完整,采样一律返回黑色;或者相机矩阵把物体推到了近裁剪面之外。

解决顺序我一般是:先在glClearColor里设一个鲜艳的颜色(比如纯红),确认清屏生效、Surface 是通的;再把MIN_FILTER临时改成GL_NEAREST,排除纹理不完整;最后把model矩阵的平移量改小,确认几何在视野内。这三步走完,九成黑屏能定位到具体环节。

5.2 纹理花屏、斜条纹或上下颠倒

现象:模型渲染出来了,但贴图是斜的彩色条纹,或者画面整体上下翻转。

原因:前者的根源是GL_UNPACK_ALIGNMENT没设成 1,三通道图片按 4 字节对齐读取就错位了;后者是图片解码库的内存布局和 OpenGL 的 UV 原点不一致,图片第 0 行在上方,OpenGL 认为第 0 行在下方。

解决:调纹理上传函数开头补一句glPixelStorei(GL_UNPACK_ALIGNMENT, 1)。上下颠倒可以把 UV 的 v 分量改成1.0 - v,或者在顶点数据里直接翻转 v。我一般选前者,因为改着色器不用重新生成 VBO。

5.3 切到后台再回来,画面黑了或者花了

现象:应用切后台待一会儿,切回来渲染区域变黑,或者出现上一次的残留画面。

原因:EGL 的 Surface 在窗口销毁时失效了,上下文也跟着丢,回来之后还在用旧的EGLSurface和纹理句柄,全是无效对象。

解决:监听 Surface 的创建和销毁回调,销毁时把纹理、VBO、着色器程序全部释放,创建时重新初始化。判断上下文是否丢失可以用eglGetError()或者检查eglMakeCurrent的返回值。后悔药就是每次都老老实实重建,别指望「大部分设备不丢」。这里加一个initialized_标志位,重建时幂等清理,能省掉很多重复初始化的坑。

5.4 双目模式画面被横向压扁

现象:VR 模式下左右眼各有一半画面,但整体看起来被拉宽了,圆变成了椭圆。

原因:投影矩阵的宽高比仍然用了整屏的width / height,而实际视口只有半屏宽。

解决:把 aspect 改成(width / 2) / height,并且每只眼的视口宽高都要传半屏值。再检查一下glViewport的 x 偏移,右眼的起点应该是width / 2而不是width,写成width画面就整个跑到屏幕外了。

5.5 帧率从 60 掉到 20 附近

现象:单目模式很流畅,一切到双目就卡。

原因:几何和纹理被完整画了两遍,draw call 翻倍,片段着色器里的三次纹理采样也翻倍,加上畸变着色器本身比普通着色器重,很容易撞上 GPU 瓶颈。

解决:先在片段里做一次texture采样、跳过畸变,看帧率是否恢复,确认是不是采样开销;如果还是卡,就把两眼的绘制合到一次里,用gl_InstanceID区分左右并配合gl_ViewportIndex(需要 ES 3.2 或扩展)。再不行就降分辨率渲染,用 FBO 画到一张半尺寸纹理上,最后放大到屏幕,视觉损失比掉帧小得多。

6. 二次开发:把 Demo 改成能用的渲染骨架

拿到这个 Demo 之后,如果只是原样跑一遍,价值有限。我一般会先做一件事:把它拆成「可复用的渲染骨架 + 可替换的业务层」。具体做法是定义一个极简的渲染接口,把 EGL 管理、着色器编译、帧循环固定下来,把「画什么」交给外部注入。

// 渲染骨架只关心生命周期和通用资源,具体内容由 Scene 提供 class Scene { public: virtual ~Scene() = default; virtual bool onInit() = 0; // 加载几何、纹理、着色器 virtual void onDraw(const Mat4& view, const Mat4& proj) = 0; virtual void onResize(int w, int h) = 0; virtual void onRelease() = 0; // 释放 GL 资源 }; class RenderHost { public: void setScene(std::unique_ptr<Scene> s) { scene_ = std::move(s); } void onSurfaceCreated() { scene_->onInit(); } void onDrawFrame() { glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); scene_->onDraw(view_, proj_); // 单目/双目差异在这里面处理 eglSwapBuffers(display_, surface_); } };

onRelease一定要有,而且要在上下文还有效的时候调用,否则纹理和缓冲会一直挂在 GPU 上,切几次后台内存就上去了。这套划分之后,加一个新场景只需要实现三个虚函数,不用碰 EGL 和窗口管理那一坨。

改造过程中的验证方法我习惯按层来:第一层验证上下文,清屏颜色能不能改;第二层验证几何,把片段着色器改成输出纯色,确认三角形位置正确;第三层验证纹理,UV 直接输出成颜色看坐标对不对;第四层才验证光照和畸变。每层都单独能跑,出问题就往下退一层,比对着黑屏猜效率高太多。

最后说个血泪经验。我第一版改这个 Demo 的时候,为了省事把双目渲染的两遍绘制写进了同一个着色器,靠 uniform 变量切换左右,结果在某一款设备上两眼的画面完全一样,怎么改参数都没用。后来才发现是自己把 viewport 的设置放到了绘制之后,两帧都用了同一个视口。从那以后我每次动视口和矩阵,都会在绘制前打印一行glGetIntegerv(GL_VIEWPORT, …)和当前 MVP 的平移分量,强制走一遍这个检查。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询