3. 多线程渲染概述

在移动端 OpenGL ES 应用中,渲染往往不是孤立发生的:它需要与触摸输入、网络回调、传感器采样、资源加载、动画逻辑等并发协作。如果所有工作都挤在同一个线程里,任何一次耗时操作都会直接阻塞渲染,表现为掉帧、卡顿甚至 ANR。多线程渲染的核心目标,就是把“必须串行、必须独占上下文”的渲染工作,与“可以并行、可以异步”的其他工作解耦,让 GPU 的提交节奏保持稳定。

多线程渲染不是“把 OpenGL ES 调用随便分到多个线程”,而是围绕 EGL 上下文、线程归属和资源同步建立一套明确的工程约束。

3.1 为什么需要多线程渲染

单线程渲染的瓶颈通常来自三个方面。第一是主线程职责过重:Android 的 UI 线程既要处理 View 树、输入事件和生命周期,又要执行 onDrawFrame 中的 GL 调用,一旦布局或业务逻辑耗时,渲染就被拖慢。第二是资源加载阻塞:纹理解码、Shader 编译、模型解析往往需要几十到几百毫秒,放在渲染线程中会造成明显卡顿。第三是 CPU 与 GPU 的流水线没有被充分利用:GPU 在绘制上一帧时,CPU 本可以准备下一帧的命令和数据。

多线程渲染带来的收益可以归纳为:

  • 帧率稳定:渲染线程只做与绘制相关的工作,减少被业务逻辑打断的概率。
  • 吞吐提升:资源加载、解码、上传等任务可以并行执行。
  • 响应更快:主线程从繁重的 GL 调用中解放出来,输入和 UI 更新更及时。
  • 职责清晰:渲染线程、加载线程、逻辑线程边界明确,便于排障和性能分析。

3.2 主线程与渲染线程分离

OpenGL ES 的上下文具有“线程亲和性”:一个 EGLContext 在同一时刻只能被一个线程设为当前上下文。因此,主线程与渲染线程分离的关键,不是共享同一个上下文,而是建立清晰的“生产者—消费者”关系。常见做法是:主线程负责 UI、输入和状态更新,渲染线程持有 EGLContext 并独占执行所有 GL 调用,两者通过线程安全队列传递渲染命令和资源句柄。

下面是一个典型的渲染线程循环骨架:

// 渲染线程主循环(伪代码)
void renderThreadLoop() {
    makeCurrent();                 // eglMakeCurrent(display, surface, surface, context)
    while (!quit) {
        RenderCommand cmd;
        if (commandQueue.pop(cmd)) {   // 阻塞或带超时地取命令
            switch (cmd.type) {
                case UPDATE_TEXTURE: uploadTexture(cmd.data); break;
                case DRAW_FRAME:     drawScene();             break;
                case RESIZE:         resizeSurface(cmd.w, cmd.h); break;
            }
        }
        eglSwapBuffers(display, surface);
    }
    releaseCurrent();
}

主线程只负责“投递命令”,不直接触碰 GL 对象。纹理、VBO、FBO 等资源由渲染线程创建和销毁,其他线程只能传递不可变的原始数据或句柄。这样既避免了上下文竞争,也避免了跨线程调用 GL 函数导致的未定义行为。

如果确实需要多个线程共享 GPU 资源,应使用共享上下文(shared context)。共享上下文之间可以共享纹理、Buffer 等对象,但 FBO、VAO 等容器对象通常不共享,且同步必须依赖 fence、事件或显式等待。

3.3 常见多线程模型与适用场景

工程中常见的多线程渲染模型可以按“谁持有上下文、谁负责提交”来划分。下表给出四种典型模型及其适用场景:

模型 线程结构 上下文归属 适用场景 主要风险
单线程模型 主线程完成逻辑与渲染 主线程唯一上下文 简单 Demo、轻量 UI 主线程阻塞即掉帧
渲染线程模型 主线程 + 独立渲染线程 渲染线程独占上下文 游戏、相机预览、复杂动画 命令队列积压、同步延迟
共享上下文模型 渲染线程 + 加载线程 多上下文共享资源 大纹理、模型异步上传 共享对象范围受限、同步复杂
多渲染线程模型 多个渲染线程分屏/分任务 每线程独立上下文 多窗口、VR 双眼、分块渲染 上下文切换开销、资源竞争

选择模型时,优先考虑“渲染线程是否独占上下文”和“资源是否需要跨线程共享”。大多数移动端应用采用“渲染线程模型”即可获得明显收益;只有在资源加载成为瓶颈时,才引入共享上下文或异步上传机制。

3.4 案例:相机预览中的双线程协作

以相机预览为例,Camera2 的回调线程持续输出 Image 数据,渲染线程负责把 YUV 转成纹理并绘制到 SurfaceView。如果两者混在一起,相机回调的抖动会直接反映到预览帧率上。分离后,回调线程只做数据入队,渲染线程按自己的节奏取帧、上传纹理、绘制。这样即使某一帧解码稍慢,也不会阻塞相机管线。

// 回调线程:只入队,不碰 GL
onImageAvailable(ImageReader reader) {
    Image image = reader.acquireLatestImage();
    frameQueue.offer(image);   // 线程安全队列
}

// 渲染线程:取帧、上传、绘制
while (running) {
    Image image = frameQueue.poll();
    if (image != null) {
        updateTexture(image);  // GL 调用只发生在这里
        drawFrame();
        image.close();
    }
    eglSwapBuffers(display, surface);
}

3.5 误区与复盘

  • 误区一:多线程一定更快。线程创建、上下文切换、命令队列同步都有成本。如果渲染任务本身很轻,多线程反而增加延迟。
  • 误区二:可以在任意线程调用 GL 函数。没有设为当前的上下文,任何 GL 调用都是无效甚至崩溃的。
  • 误区三:共享上下文等于完全共享。FBO、VAO、Query 等对象通常不共享,跨线程使用前必须确认对象类型和同步方式。
  • 误区四:队列无界。无界队列会导致内存上涨和延迟累积,应使用有界队列并配合丢帧策略。
多线程渲染的调试难度高于单线程。建议在早期就加入帧耗时、队列长度、上下文切换次数等指标,避免问题在后期集中爆发。
主线程与渲染线程分离模型 主线程 / UI 线程 输入事件 · 生命周期 业务逻辑 · 状态更新 投递渲染命令 不直接调用 GL 线程安全命令队列 有界 · 可丢帧 · 带同步 Draw / Update / Resize 渲染线程 独占 EGLContext 执行 GL 调用 eglSwapBuffers 资源创建与销毁 入队 出队 资源加载线程 解码图片 · 解析模型 编译 Shader 源码 产出原始数据 / 句柄 共享上下文 共享纹理 / Buffer Fence 同步 FBO / VAO 不共享 上传 同步后使用 关键约束 上下文线程亲和 GL 调用只在当前线程 队列有界 · 及时丢帧