2. OpenGL ES基础回顾

在进入多线程渲染工程之前,必须先把 OpenGL ES 的基础对象和运行机制重新梳理一遍。多线程渲染并不是简单地“把绘制调用放到另一个线程”,它依赖对上下文、缓冲区、纹理、着色器和管线状态的精确理解。只有知道哪些资源属于上下文、哪些状态是线程私有的、哪些操作会触发同步,才能设计出正确的线程模型。

一、版本演进:从固定管线到可编程管线

OpenGL ES 是 OpenGL 的嵌入式子集,主要服务于移动端和嵌入式 GPU。它的版本演进决定了我们今天能使用哪些特性:

  • OpenGL ES 1.x:固定管线,顶点变换、光照、纹理混合由硬件固定阶段完成,开发者通过 glMatrixMode、glLight 等接口配置状态。
  • OpenGL ES 2.0:引入可编程着色器,必须提供顶点着色器和片元着色器,彻底移除固定管线。它是移动端最广泛兼容的基线版本。
  • OpenGL ES 3.0/3.1/3.2:增加 MRT、实例化、纹理数组、Uniform Buffer Object、计算着色器(3.1+)、几何着色器(3.2)等能力,同时保持向后兼容。

在多线程工程中,版本选择直接影响上下文创建方式和资源共享策略。例如 ES 2.0 没有 UBO,Uniform 更新更依赖上下文绑定;ES 3.0 之后可以用 UBO 减少每帧的 Uniform 调用,从而降低线程间同步压力。

二、渲染管线:数据如何从顶点走到屏幕

OpenGL ES 的可编程管线可以概括为一条从顶点数据到帧缓冲像素的数据流。理解这条数据流,是理解多线程渲染中“哪些阶段可以并行、哪些必须串行”的关键。

OpenGL ES 可编程渲染管线:数据流与可并行边界 顶点数据 VBO / VAO 顶点着色器 逐顶点并行 图元装配 / 裁剪 三角形、线段、点 光栅化 生成片元 片元着色器 逐片元并行 逐片元操作 深度 / 混合 / 模板 帧缓冲 颜色 / 深度 / 模板 多线程视角下的关键边界 • 顶点/片元着色器内部由 GPU 大规模并行执行 • 管线状态(绑定、混合、深度)属于上下文,不能跨线程随意修改 • VBO/纹理上传可与绘制并行,但需同步点或共享上下文 • 帧缓冲写入是管线末端,必须保证同一帧内顺序一致 • 多线程优化的核心:把 CPU 侧准备与 GPU 侧执行解耦

从图中可以看到,顶点着色器和片元着色器是 GPU 内部高度并行的阶段,而管线状态、绑定关系和帧缓冲写入则属于上下文级别的串行资源。多线程渲染要做的,是把 CPU 侧的顶点数据准备、纹理上传、着色器编译等耗时操作放到工作线程,同时保证这些操作与渲染线程之间的资源同步正确。

三、着色器、纹理与缓冲区:三类核心资源

OpenGL ES 中的资源大致可以分为三类,它们在多线程环境中的行为差异很大:

资源类型 典型对象 创建/编译开销 多线程注意事项
着色器程序 glCreateShader、glCompileShader、glLinkProgram 高,编译和链接可能耗时数十毫秒 可在工作线程编译,但最终链接和绑定需在共享上下文或渲染线程完成
纹理 glGenTextures、glTexImage2D、glTexSubImage2D 中到高,取决于尺寸和格式 上传可异步,但需避免与采样同时发生;PBO 可减少阻塞
缓冲区 glGenBuffers、glBufferData、glBufferSubData 中,VBO/IBO 更新频繁 可用多缓冲或映射方式,注意 glMapBufferRange 的同步语义

着色器是 GPU 上运行的程序。顶点着色器负责把顶点从模型空间变换到裁剪空间,片元着色器负责计算每个像素的颜色。它们通过 attribute、uniform、varying(ES 3.0 中为 in/out)传递数据。纹理是 GPU 采样的图像资源,缓冲区则是顶点属性、索引和 uniform 数据的存储载体。

四、上下文:OpenGL ES 的状态容器

OpenGL ES 的上下文(Context)保存了当前所有的管线状态、绑定对象和资源句柄。它不是线程安全的,同一时刻一个上下文只能被一个线程设为当前。多线程渲染的常见做法有两种:

  • 共享上下文:多个线程各自持有上下文,通过 EGL 的 share context 共享纹理、缓冲区等资源。适合资源上传线程与渲染线程并行。
  • 单上下文 + 任务队列:渲染线程独占上下文,工作线程只做 CPU 侧数据准备,通过队列把命令交给渲染线程执行。实现简单,但并行度有限。
提示:共享上下文中的“共享”通常只覆盖纹理、缓冲区、着色器程序等资源对象,不共享帧缓冲、VAO 和管线状态。因此,工作线程不能直接修改渲染线程的 VAO 绑定。

五、案例场景:纹理上传与渲染并行

假设一个场景需要每帧从网络加载一张新纹理并立即渲染。如果全部在渲染线程完成,纹理上传会阻塞绘制,导致帧率抖动。更合理的做法是:工作线程在共享上下文中创建纹理并上传像素数据,完成后通过同步对象通知渲染线程;渲染线程只负责绑定和绘制。

// 工作线程(共享上下文)
glGenTextures(1, &texId);
glBindTexture(GL_TEXTURE_2D, texId);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
glBindTexture(GL_TEXTURE_2D, 0);
// 通过 fence 或事件通知渲染线程

// 渲染线程
glBindTexture(GL_TEXTURE_2D, texId);
glDrawElements(GL_TRIANGLES, indexCount, GL_UNSIGNED_SHORT, 0);

这个案例说明:纹理对象本身可以在共享上下文中创建,但绑定和绘制必须发生在渲染线程的上下文中。两者之间的同步点,正是多线程渲染工程需要重点设计的部分。

六、常见误区与复盘

  • 误区一:认为所有 OpenGL ES 调用都可以跨线程。实际上,上下文绑定、VAO、帧缓冲等状态是线程私有的,跨线程调用会导致未定义行为。
  • 误区二:忽略着色器编译的耗时。在移动端,复杂着色器编译可能超过一帧时间,应提前编译或放到工作线程。
  • 误区三:共享上下文后直接复用 VAO。VAO 不参与共享,工作线程创建的 VAO 在渲染线程不可见。
  • 误区四:不做同步就上传纹理。如果渲染线程正在采样同一纹理,上传操作可能造成画面撕裂或驱动崩溃。
注意:多线程渲染的第一原则是“先保证正确,再追求并行”。任何资源共享都必须有明确的同步机制,例如 EGL 同步对象、栅栏或双缓冲队列。