4. GPU渲染优化:OpenGL ES状态机管理、纹理压缩与上传优化、VBO/VAO/FBO的正确使用
说到GPU渲染优化,很多同学第一反应就是“少画点东西”。其实没那么简单。GPU是个状态机,你每次调用glEnable、glBindTexture,都是在改变它的内部状态。状态切换多了,性能就崩了。我见过不少项目,帧率上不去,查了半天发现是状态管理太乱。
这一章,我重点讲三个方向:状态机管理、纹理优化、以及VBO/VAO/FBO的正确用法。这些都是我在实际项目中踩过坑的地方。
4.1 OpenGL ES状态机管理
OpenGL ES本质上是一个巨大的状态机。你调用的每个API,都是在修改某个状态。比如glEnable(GL_BLEND)打开了混合,glBindTexture绑定了纹理。这些状态会一直保持,直到你再次修改。
问题出在哪?状态切换是有代价的。GPU需要刷新管线、重新配置硬件单元。你想想看,每帧切换几十次纹理、混合模式、深度测试,帧率能高才怪。
核心原则:尽量减少状态切换次数。能批量处理的,就批量处理。
4.1.1 状态排序与分组
我个人习惯的做法是:按状态分组渲染。比如把所有需要混合的对象放在一起渲染,所有不需要混合的放在另一批。这样你只需要切换一次混合状态。
举个例子:
// 不好的做法:频繁切换状态
for (Object obj : objects) {
if (obj.needBlend) {
glEnable(GL_BLEND);
} else {
glDisable(GL_BLEND);
}
draw(obj);
}
// 好的做法:按状态分组
glDisable(GL_BLEND);
for (Object obj : opaqueObjects) {
draw(obj);
}
glEnable(GL_BLEND);
for (Object obj : transparentObjects) {
draw(obj);
}
我在项目中遇到过类似的情况。有一次做AR应用,画面里同时有半透明的UI和实体的3D模型。一开始没做分组,每帧切换混合状态几十次,帧率只有20fps。后来改成按状态分组渲染,直接飙到55fps。差别就这么大。
4.1.2 避免冗余状态设置
还有一个常见的坑:重复设置相同的状态。比如每帧都调用glEnable(GL_DEPTH_TEST),但深度测试本来就是开启的。这种冗余调用虽然不会报错,但会浪费CPU时间。
我建议的做法是:维护一个状态缓存。记录当前状态,只有需要改变时才调用API。
// 状态缓存示例
private int currentBlendMode = -1;
void setBlendMode(int mode) {
if (mode != currentBlendMode) {
if (mode == GL_BLEND) {
glEnable(GL_BLEND);
} else {
glDisable(GL_BLEND);
}
currentBlendMode = mode;
}
}
嗯,这里要注意:状态缓存只对单线程有效。如果你用了多线程渲染,那就得加锁了。不过Android上大多数情况还是单线程渲染,问题不大。
4.2 纹理压缩与上传优化
纹理是GPU内存的大户。一张1080p的RGBA纹理,不压缩的话要占8MB。如果场景里有几十张纹理,显存直接爆了。而且纹理上传到GPU也是个耗时操作,搞不好就会卡主线程。
4.2.1 纹理压缩格式选择
Android上主流的纹理压缩格式有ETC2、ASTC、PVRTC等。我个人的建议是:
- ETC2:Android 4.3+ 强制支持,兼容性最好。压缩比4:1,质量还行。
- ASTC:压缩比可调(4x4到12x12),质量更好。但需要GPU支持(Mali、Adreno 6xx+)。
- PVRTC:PowerVR GPU专用,其他平台别用。
| 格式 | 压缩比 | 质量 | 兼容性 |
|---|---|---|---|
| ETC2 | 4:1 | 中等 | Android 4.3+ 全支持 |
| ASTC 6x6 | 5.3:1 | 高 | Mali、Adreno 6xx+ |
| PVRTC 4bpp | 8:1 | 中等 | PowerVR GPU |
我曾经在一个项目中用了未压缩的纹理,结果在低端机上直接OOM。后来全部转成ETC2,内存占用降了70%,而且画质几乎看不出区别。说白了,纹理压缩是性价比最高的优化手段之一。
4.2.2 纹理上传优化
纹理上传到GPU,说白了就是把CPU内存的数据拷贝到显存。这个过程如果放在主线程,会阻塞UI渲染。我建议的做法是:
- 使用异步上传:通过glTexImage2D的PBO(Pixel Buffer Object)实现异步。
- 预加载纹理:在场景加载阶段就把纹理上传好,不要在渲染循环里动态上传。
- 纹理复用:相同的纹理只上传一次,用引用计数管理。
// 使用PBO异步上传纹理
int[] pboIds = new int[1];
glGenBuffers(1, pboIds, 0);
glBindBuffer(GL_PIXEL_UNPACK_BUFFER, pboIds[0]);
glBufferData(GL_PIXEL_UNPACK_BUFFER, data.length, null, GL_STREAM_DRAW);
// 映射PBO到CPU内存
ByteBuffer buffer = glMapBufferRange(GL_PIXEL_UNPACK_BUFFER, 0, data.length, GL_MAP_WRITE_BIT);
buffer.put(data);
glUnmapBuffer(GL_PIXEL_UNPACK_BUFFER);
// 上传纹理(此时是异步的)
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, 0);
这里有个坑:PBO在低端设备上可能不支持。所以最好做个兼容性判断,不行就回退到同步上传。
4.3 VBO/VAO/FBO的正确使用
这三个东西是OpenGL ES里管理顶点数据和帧缓冲的核心工具。用对了,性能起飞;用错了,各种诡异问题。
4.3.1 VBO(顶点缓冲对象)
VBO的作用是把顶点数据(位置、颜色、纹理坐标等)存到GPU显存里,避免每帧都从CPU上传。说白了,就是减少CPU和GPU之间的数据传输。
我建议的做法:
- 静态数据用GL_STATIC_DRAW:比如模型的顶点坐标,上传一次就不变了。
- 动态数据用GL_DYNAMIC_DRAW或GL_STREAM_DRAW:比如粒子系统的位置,每帧都在变。
- 合并VBO:多个小模型可以合并到一个VBO里,减少绑定次数。
// 创建VBO
int[] vboIds = new int[1];
glGenBuffers(1, vboIds, 0);
glBindBuffer(GL_ARRAY_BUFFER, vboIds[0]);
glBufferData(GL_ARRAY_BUFFER, vertexData.length * 4, vertexData, GL_STATIC_DRAW);
// 设置顶点属性
glVertexAttribPointer(0, 3, GL_FLOAT, false, 6 * 4, 0); // 位置
glVertexAttribPointer(1, 3, GL_FLOAT, false, 6 * 4, 3 * 4); // 颜色
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
4.3.2 VAO(顶点数组对象)
VAO是用来管理VBO的“状态快照”。它记录了所有顶点属性的配置(哪个VBO、哪个属性、偏移量等)。每次渲染时,只需要绑定VAO,不用重新设置顶点属性。
为什么要用VAO?因为glVertexAttribPointer和glEnableVertexAttribArray这些调用是有开销的。VAO把这些配置打包起来,一次绑定就搞定。
小技巧:在初始化阶段创建好VAO,渲染循环里只调用glBindVertexArray。这样能减少每帧的API调用次数。
// 创建VAO
int[] vaoIds = new int[1];
glGenVertexArrays(1, vaoIds, 0);
glBindVertexArray(vaoIds[0]);
// 绑定VBO并设置属性(这些配置会记录在VAO中)
glBindBuffer(GL_ARRAY_BUFFER, vboId);
glVertexAttribPointer(0, 3, GL_FLOAT, false, 6 * 4, 0);
glEnableVertexAttribArray(0);
// 渲染时只需要绑定VAO
glBindVertexArray(vaoId);
glDrawArrays(GL_TRIANGLES, 0, vertexCount);
4.3.3 FBO(帧缓冲对象)
FBO用于离屏渲染。比如你要做后处理特效(模糊、辉光),或者渲染到纹理上供后续使用。FBO的好处是不需要创建额外的窗口或Surface。
我遇到过的一个坑:FBO的附件(颜色缓冲、深度缓冲)必须完整。如果你只绑定了颜色纹理,没绑定深度缓冲,那深度测试就失效了。曾经有一次做阴影贴图,忘了绑定深度缓冲,结果阴影死活出不来。排查了半天才发现是FBO配置不全。
// 创建FBO
int[] fboIds = new int[1];
glGenFramebuffers(1, fboIds, 0);
glBindFramebuffer(GL_FRAMEBUFFER, fboIds[0]);
// 绑定颜色纹理附件
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorTexId, 0);
// 绑定深度缓冲附件
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthTexId, 0);
// 检查FBO是否完整
int status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (status != GL_FRAMEBUFFER_COMPLETE) {
// 处理错误
}
警告:FBO切换也是有开销的。不要每帧频繁切换FBO。如果可能,把需要离屏渲染的操作合并到一次FBO绑定中完成。
4.4 本章知识体系
下面这张图总结了本章的核心内容,方便你回顾:
好了,这一章的内容就到这里。GPU渲染优化是个系统工程,状态管理、纹理优化、VBO/VAO/FBO这三块缺一不可。你在实际项目中可以先从纹理压缩入手,效果立竿见影。然后再逐步优化状态管理和顶点数据。别想着一步到位,优化是个持续迭代的过程。