返回

OpenGL纹理后台加载:告别卡顿,流畅渲染

Android

OpenGL 纹理后台加载:避免主线程卡顿

开发移动应用,特别是涉及到图形渲染的时候,经常会遇到一个棘手问题:加载纹理会阻塞主线程,导致界面卡顿,掉帧。这可咋整?别慌,咱今天就来聊聊怎么解决这个问题。

一、 问题:纹理加载导致卡顿

你在开发一个用到 OpenGL 渲染纹理的 APP,目标是实现类似 Instagram 图片加载的流畅滚动效果。目前你是这么做的:

  • 用一个后台线程异步加载纹理。
  • 纹理加载完成后,发到主线程进行渲染。
  • OpenGL 纹理创建过程包括:先生成一个 Android Bitmap,然后调用 glGenTexturesglBindTexture 绑定纹理,最后用 texImage2D 把 Bitmap 加载到纹理。

但问题来了:在后台线程创建纹理时,主线程会变慢,导致掉帧,滚动时尤其明显。很明显,卡顿的原因是纹理创建操作与渲染操作冲突了。

二、 为啥会卡?深挖原因!

想要搞定卡顿,就得先弄明白,为啥这纹理加载会影响主线程。主要有以下几个原因:

  1. OpenGL Context 共享的坑: 大多数情况下,后台线程和主线程会共享一个 OpenGL Context。这意味着虽然你在后台线程创建纹理,但这个操作还是得通过共享的 Context 来完成,而这个过程可能会涉及到一些同步操作,就有可能阻塞主线程。
  2. texImage2D 的性能问题: texImage2D 这个函数本身就是一个比较耗时的操作,尤其是在处理大尺寸图片的时候。它涉及到数据的复制和格式转换等,如果放在主线程,那肯定卡得飞起。就算你把它放到后台线程,它仍然会占用共享的 OpenGL Context 的一部分操作, 与渲染竞争。
  3. Bitmap 加载和解码: Android 里面,创建 Bitmap 本身也是个可能很耗时的操作,图片越大越明显。即使在后台加载, 产生大量Bitmap也会消耗资源,尤其内存占用。

三、 解决方案:多管齐下,药到病除!

既然知道病根在哪儿,那咱就好对症下药了。下面几个方法,你可以根据自己的实际情况来选择,也可以组合使用,效果更佳。

1. PBO(像素缓冲区对象):更快的数据传输

原理和作用: PBO (Pixel Buffer Object) 是 OpenGL 里面用来做异步数据传输的利器。你可以把 PBO 想象成一个在 GPU 端的缓冲区,我们把像素数据先放到 PBO 里面,然后再从 PBO 传输到纹理。这样可以利用 GPU 的并行处理能力,减少 CPU 的负担,并且避免阻塞主线程的渲染操作。

代码示例 (Java/Kotlin, 伪代码):

// 后台线程:
// 1. 生成 PBO
int[] pbos = new int[1];
GLES20.glGenBuffers(1, pbos, 0);
int pbo = pbos[0];

// 2. 绑定 PBO
GLES20.glBindBuffer(GLES20.GL_PIXEL_UNPACK_BUFFER, pbo);

// 3. 分配 PBO 空间 (和 Bitmap 大小一致)
GLES20.glBufferData(GLES20.GL_PIXEL_UNPACK_BUFFER, bitmap.getByteCount(), null, GLES20.GL_STREAM_DRAW);

// 4. 将 Bitmap 数据映射到 PBO
ByteBuffer byteBuffer = (ByteBuffer) GLES20.glMapBufferRange(GLES20.GL_PIXEL_UNPACK_BUFFER, 0, bitmap.getByteCount(), GLES20.GL_MAP_WRITE_BIT | GLES20.GL_MAP_INVALIDATE_BUFFER_BIT);
bitmap.copyPixelsToBuffer(byteBuffer);
GLES20.glUnmapBuffer(GLES20.GL_PIXEL_UNPACK_BUFFER);

// 5. 解绑 PBO
GLES20.glBindBuffer(GLES20.GL_PIXEL_UNPACK_BUFFER, 0);
bitmap.recycle(); //图片读取完尽快recycle.
// 主线程 (在 OpenGL 渲染循环中):

// 6. 创建并绑定纹理
int[] textures = new int[1];
GLES20.glGenTextures(1, textures, 0);
int textureId = textures[0];
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, textureId);

// 设置纹理参数(例如:过滤方式、环绕方式等)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE);
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE);

// 7. 绑定 PBO
GLES20.glBindBuffer(GLES20.GL_PIXEL_UNPACK_BUFFER, pbo);

// 8. 使用 glTexImage2D,数据源为 PBO (最后一个参数为 0 表示从当前绑定的 PBO 中读取数据)
GLES20.glTexImage2D(GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, width, height, 0, GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, 0);

// 9. 解绑 PBO
GLES20.glBindBuffer(GLES20.GL_PIXEL_UNPACK_BUFFER, 0);
GLES20.glDeleteBuffers(1,pbos,0);

// 注意: PBO 的删除可以在纹理使用完毕后进行,以避免不必要的开销

进阶技巧: 多个 PBO 交替使用,可以进一步提升效率。比如,用两个 PBO,一个用来上传数据到纹理,另一个同时在后台准备下一帧的数据,这样可以最大限度地利用 GPU 的并行性。

2. EGLImage:共享纹理数据(如果适用)

原理和作用: EGLImage 是 EGL 的一个扩展,允许你在不同的 OpenGL Context 之间共享纹理数据。如果在你的应用中,主线程和后台线程可以分别使用独立的 OpenGL Context,那么 EGLImage 可能是个不错的选择。 你可以在后台线程的Context中完成Bitmap的加载和处理, 然后直接在主线程使用该Context中加载的Texture数据. 这样避免数据的传输开销.

使用方法:

由于EGLImage 的用法相对复杂,牵扯到 EGL Context 的创建和管理, 通常在 C/C++ (NDK) 环境下使用, 我这里就不给出完整的代码示例了,只简单介绍一下流程:

  1. 在后台线程创建一个 EGL Context,并与主线程的 EGL Context 共享数据。
  2. 在后台线程加载 Bitmap 并创建纹理。
  3. 将后台线程创建的纹理封装成 EGLImage。
  4. 在主线程中,通过 EGLImage 创建一个与后台线程纹理共享数据的纹理对象。

安全建议: 使用 EGLImage 时,务必注意同步问题。如果两个 Context 同时对同一个纹理进行操作,可能会导致未定义的行为。需要仔细设计你的线程同步机制,确保数据的一致性。

3. 减少 texImage2D 的调用

原理和作用: 尽量减少不必要的 texImage2D 调用。例如:
* 如果图片内容没有变化, 直接复用已经创建的纹理,避免重复上传数据。
* 使用ETC,ASTC等纹理压缩格式,这些格式可以在上传前进行更高效地压缩,在运行时有硬件解压加速。
* 合并小图片, 合成雪碧图或纹理图集, 把许多小图片合并到一张大图片中,一次性加载, 然后通过 UV 坐标来访问。可以大大减少 OpenGL 绘图调用的次数,从而提升渲染效率。

4. 控制后台线程数量

你的问题还提到了,8 核 CPU,开几个后台线程比较好? 这个没有标准答案, 因为最佳线程数量取决于很多因素,例如设备性能、图片大小、解码复杂度等等。 但还是有些通用的建议:

  • 不要开太多! 开太多线程反而会导致线程切换开销增加,性能下降。一般建议,后台线程数量不要超过 CPU 核心数的一半。 对于你的8核CPU手机,开2-4 个线程可能比较合适。
  • 动态调整: 可以根据设备的负载情况动态调整线程数量。比如,检测到当前 CPU 利用率比较高,就适当减少线程数量,反之亦然。

实际测试: 最好的办法还是实际测试,通过调整线程数量,并监测你的APP 性能,找到一个最佳平衡点。

5. 纹理格式和大小优化

  • 使用合适的纹理格式: 尽量使用如GL_RGBA8之类的常见纹理格式, 而不是 GL_RGB 这种的, 避免做格式转化。
  • 减小图片尺寸: 在不影响显示效果的前提下, 尽量缩小图片的尺寸。 例如先加载低分辨率版本,待高分辨率纹理准备就绪时无缝切换(可使用 Level of Detail(LOD))。

6. 使用单独的 OpenGL Context(非共享) 加载

可以完全分离为两个 EGLContext 和两个线程:一个渲染, 一个纹理加载. 然后再将已经上传的数据用同步手段传输到主渲染线程.
需要注意的是, 如果没有显式支持多个 Context, 这需要 NDK 层面的更多操作. 需要开发者对 OpenGL 底层更为了解.

结语

OpenGL 纹理后台加载是个挺有挑战性的问题,需要综合考虑多个方面才能达到最佳效果. 希望本文给出的解决方案能帮你解决这个问题. 让你的APP跑得更流畅!