拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

GLES2渲染优化实战:PvZ-Portable批处理与状态缓存提升帧率

GLES2渲染优化实战:PvZ-Portable批处理与状态缓存提升帧率

1. 从一次掉帧说起:PvZ-Portable 渲染路径到底卡在哪

植物大战僵尸的移植版在社区里一直有人折腾,PvZ-Portable 算是其中完成度比较高的一个方向。它把原本跑在特定平台上的游戏逻辑抽出来,用相对通用的图形接口重新实现渲染,让这套老游戏能在更多设备上跑起来。我最初接触这个项目的时候,想法很简单:一个 2D 塔防游戏,画面元素无非就是草坪、植物、僵尸、子弹、阳光,能有多吃性能?结果实测下来,在中低端安卓设备上,僵尸一多、子弹一密集,帧率就开始往下掉,尤其是那种满屏豌豆射手齐射的场面,掉帧非常明显。

问题不在游戏逻辑,逻辑部分跑得飞快。瓶颈全在渲染路径上。具体来说,是每一帧都在重复做大量本可以避免的工作:重复的矩阵计算、重复的状态切换、重复的纹理绑定、重复的绘制调用。这些东西单看一次开销都不大,但一帧里累积成百上千次,就成了压垮帧率的最后一根稻草。

这篇文章我想聊的就是怎么把这条渲染路径上的重复工作砍掉。核心思路围绕GLES2和OpenGL这套移动端常见的图形接口展开,因为 PvZ-Portable 在移动端主要就是靠 GLES2 来出画面的。如果你正在做手游性能优化,或者手头有个用 OpenGL 渲染的 2D 项目觉得帧率上不去,这里面的思路应该能直接抄作业。我会把每一步为什么这么做、参数怎么定、坑在哪里都讲清楚,尽量让你看完就能动手改。

先说结论方向:渲染路径优化的本质,是把每帧重复计算的东西缓存下来,把每帧重复提交的东西合并起来,把每帧不必要的状态切换去掉。听起来像废话,但真正落到代码里,每一块都有讲究。

2. 渲染路径的整体设计与优化思路拆解

2.1 先搞清楚一帧里到底发生了什么

在动手优化之前,必须先把当前渲染路径摸清楚。PvZ-Portable 的渲染大致是这样一条链路:游戏逻辑更新完所有实体的位置和状态之后,进入渲染阶段,遍历所有需要绘制的对象,对每个对象做一次完整的绘制流程。这个流程包括几个步骤:计算这个对象的世界变换矩阵,把矩阵传给着色器,绑定这个对象用的纹理,设置混合模式等渲染状态,最后发起一次绘制调用。

一帧里如果有 200 个可见对象,这条链路就走 200 遍。问题在于,这 200 个对象里,绝大多数用的是同一张纹理图集,混合模式也完全一样,变换矩阵里大部分只是平移不同。也就是说,200 次流程里,真正有差异的部分可能只占很小一块,剩下的全是重复劳动。

我做过一个粗略的统计,在一个僵尸密集的场面里,单帧的绘制调用能到 300 次以上,其中纹理绑定操作有 300 次左右,但实际用到的不同纹理只有个位数。矩阵上传操作 300 次,但其中旋转和缩放的变化极少,大部分只是位置不同。这就是典型的重复工作。

2.2 优化的三个层次

我把优化分成三个层次,从易到难,收益也从小到大。

第一个层次是减少重复计算。矩阵、颜色、UV 这些每帧都要算的东西,能缓存的就缓存,能预计算的就在初始化阶段算好。这个层次改动最小,风险最低,收益也比较直接。

第二个层次是合并绘制调用。把用同一张纹理、同一套渲染状态的对象攒到一起,一次性提交。这就是常说的批处理(batching)。这个层次改动中等,需要重新组织渲染数据的结构,但收益很大,绘制调用能从几百降到几十甚至个位数。

第三个层次是消除冗余状态切换。OpenGL 的状态机是有开销的,每次切换纹理、切换混合模式、切换着色器程序,驱动层都要做一堆校验和准备工作。把相同状态的绘制排在一起,避免来回切换,能省下不少开销。

这三个层次不是互斥的,实际做的时候是叠加的。我建议的顺序是先做第一层,把能缓存的都缓存了,再做第三层,把状态切换理顺,最后做第二层,上批处理。因为批处理会改变数据组织方式,如果前面没理顺,后面改起来会很乱。

2.3 为什么选 GLES2 作为优化基准

有人可能会问,现在都什么年代了,为什么还在 GLES2 上做优化,不直接上 GLES3 或者 Vulkan?这个问题很实际。PvZ-Portable 的目标是尽可能广的设备覆盖,包括一些比较老的安卓机。GLES2 的兼容性是最好的,几乎所有的移动 GPU 都支持。GLES3 虽然功能更强,但在一些低端设备上支持不完整,Vulkan 就更不用说了,驱动质量参差不齐。

而且对于 2D 游戏来说,GLES2 的能力完全够用。我们不需要复杂的几何着色器,不需要计算着色器,就是简单的纹理贴图和混合。在这个前提下,把 GLES2 的渲染路径优化到极致,比换一个更高级的接口但优化不到位,效果要好得多。

提示:如果你的项目已经确定只跑在新设备上,GLES3 的实例化绘制(instancing)能进一步减少绘制调用,但 GLES2 没有这个特性,所以批处理只能靠手动合并顶点数据来实现。这是 GLES2 优化的一个关键约束,后面会详细讲。

3. 核心细节解析与实操要点

3.1 矩阵计算的缓存策略

先说矩阵。每个可绘制对象都需要一个模型矩阵,把局部坐标变换到世界坐标。在 PvZ-Portable 里,大部分对象只有平移,没有旋转和缩放。原来的做法是每个对象每帧都重新构造一个 4x4 矩阵,然后上传给着色器。

这里有两个浪费。第一,构造矩阵本身有计算开销,虽然不大,但乘以几百次就不小了。第二,上传矩阵是一次 GL 调用,几百次上传就是几百次调用。

我的做法是把矩阵计算拆开。对于只有平移的对象,模型矩阵其实就是一个平移矩阵,而平移矩阵和投影矩阵相乘的结果,可以预先算好一个基础矩阵,然后每个对象只需要在这个基础上加上自己的偏移量。更进一步,如果投影矩阵在一帧内不变(通常是这样),那么可以把投影矩阵和视图矩阵预先乘好,存成一个常量,每个对象只需要传一个二维偏移量给着色器,让着色器自己完成最终的坐标计算。

具体来说,着色器里可以这样写:

// vertex shader attribute vec2 a_position; attribute vec2 a_texCoord; uniform mat4 u_mvp; // 投影 * 视图,一帧内不变 uniform vec2 u_offset; // 每个对象的平移偏移 uniform vec2 u_scale; // 缩放,没有缩放时传 (1,1) varying vec2 v_texCoord; void main() { vec4 worldPos = u_mvp * vec4(a_position * u_scale + u_offset, 0.0, 1.0); gl_Position = worldPos; v_texCoord = a_texCoord; }

这样每个对象只需要上传u_offset和u_scale两个 vec2,而不是一个完整的 mat4。上传的数据量从 64 字节降到 16 字节,而且省掉了 CPU 端的矩阵乘法。实测下来,这一项在对象数量多的时候能省下可观的 CPU 时间。

注意:u_mvp这个 uniform 在一帧内只需要设置一次,不要在每个对象绘制前都设置。GL 的 uniform 设置是有开销的,尤其是当驱动需要重新编译着色器状态的时候。把不变的 uniform 提到循环外面,是很容易被忽略的优化点。

3.2 纹理绑定的合并与图集化

纹理绑定是渲染路径上的另一个大头。每次glBindTexture调用,驱动都要检查这个纹理是否已经绑定,如果没有则执行绑定操作,可能还涉及到纹理参数的重新设置。在 PvZ-Portable 里,原来每个对象绘制前都绑定一次纹理,即使连续几个对象用的是同一张纹理。

最直接的优化是加一个缓存:记录当前绑定的纹理 ID,如果这次要绑的和上次一样,就跳过。这个改动很小,但效果立竿见影。我实测在一个典型场面里,纹理绑定调用从 300 多次降到了 20 多次。

但光靠缓存还不够,因为对象之间的纹理使用顺序是随机的,缓存命中率有限。更彻底的做法是图集化:把所有小图打包到一张大图里,所有对象都用这一张图集,这样纹理绑定就只需要一次。PvZ-Portable 本身就有图集的概念,但原来的实现里图集分了好几张,而且对象绘制顺序没有按图集排。

我的做法是把所有静态资源合并到一张 2048x2048 的图集里(这个尺寸在 GLES2 设备上兼容性最好,最大纹理尺寸低于这个值的设备很少),然后调整绘制顺序,让同一张图集的对象连续绘制。这样纹理绑定基本就固定在一次了。

图集化的关键是 UV 坐标的重新计算。每个精灵在图集里的位置变了,对应的 UV 也要跟着变。这部分最好在资源加载阶段就处理好,把每个精灵的 UV 矩形预先算好存起来,渲染时直接取用,不要在每帧里现算。

3.3 渲染状态排序与状态机优化

OpenGL 是个状态机,混合模式、深度测试、剔除模式这些都是状态。每次状态改变,驱动都要做相应处理。在 PvZ-Portable 里,不同对象的混合模式可能不同,比如普通精灵用普通混合,发光效果用叠加混合。如果绘制顺序不按混合模式排,就会频繁切换状态。

我的做法是在每帧开始渲染前,先对所有可见对象做一次排序,排序的键依次是:着色器程序、纹理、混合模式。排序之后,相同状态的对象就聚在一起了,状态切换次数大幅减少。

排序本身有开销,但对象数量在几百这个量级时,排序的开销远小于状态切换省下来的开销。如果对象数量特别大,可以考虑用桶排序或者基数排序,但对于 PvZ 这种规模,标准库的排序足够了。

这里有个细节:排序会改变绘制顺序,而 2D 游戏的绘制顺序往往决定了遮挡关系。所以排序的键里必须包含一个层级或者 Z 值,保证遮挡关系正确。我的做法是给每个对象一个渲染层级,排序时先按层级排,同层内再按状态排。这样既保证了遮挡正确,又优化了状态切换。

3.4 顶点数据的组织与批处理

批处理是收益最大的一步。核心思想是把多个对象的顶点数据合并到一个缓冲区里,一次绘制调用画完。在 GLES2 里没有实例化绘制,所以只能手动合并。

具体做法是维护一个动态顶点缓冲区,每帧把所有要绘制的对象的顶点按顺序写进去,然后一次glDrawArrays或者glDrawElements画完。每个对象的顶点数据包括位置、UV、颜色。位置是对象在屏幕上的位置加上精灵的局部坐标,UV 是精灵在图集里的 UV,颜色用于 tint 效果。

合并的时候要注意,只有状态相同的对象才能合到一批里。所以批处理的单位是“状态批次”,每个批次内的对象共享着色器、纹理和混合模式。一帧里可能有几个批次,每个批次一次绘制调用。

顶点缓冲区的管理是个关键点。不要每帧重新创建缓冲区,那样开销很大。正确的做法是创建一个足够大的缓冲区,每帧更新里面的数据。GLES2 里可以用glBufferSubData来更新,或者用glMapBufferOES直接映射内存写入(这个扩展在 GLES2 设备上支持度不错,但需要检查)。

我用的方案是双缓冲:准备两个顶点缓冲区,一帧写这个,下一帧写那个,避免写入时 GPU 还在读上一帧的数据。这个技巧在移动端尤其重要,因为移动 GPU 的架构和桌面不同,对缓冲区的读写冲突更敏感。

提示:批处理的大小要控制。一次绘制太多顶点可能会超出驱动的内部限制,导致性能反而下降。我的经验是每批控制在 1000 到 2000 个顶点左右比较稳妥,超过这个数就拆成多批。具体阈值因设备而异,需要实测。

4. 实操过程与核心环节实现

4.1 改造前的基准测试

动手之前先建立基准。我在一台中端安卓设备上跑了一个固定的测试场景:满屏豌豆射手对着一波僵尸持续射击,持续 60 秒,记录平均帧率和最低帧率。改造前的数据是平均 42 帧,最低 28 帧,卡顿感明显。

同时用 GPU 调试工具抓了一帧的渲染调用,统计下来:绘制调用 312 次,纹理绑定 318 次,uniform 上传 900 多次,状态切换 200 多次。这些数字就是优化的靶子。

4.2 第一步:矩阵与 uniform 的缓存改造

先改矩阵部分。把投影矩阵和视图矩阵预先乘好,存成一个全局的 mat4,每帧只更新一次。每个对象的绘制循环里,只上传 offset 和 scale。

代码结构大致是这样:

// 每帧开始时设置一次 glUseProgram(program); glUniformMatrix4fv(u_mvp_location, 1, GL_FALSE, mvp.data()); // 每个对象绘制时 glUniform2f(u_offset_location, obj.x, obj.y); glUniform2f(u_scale_location, obj.scaleX, obj.scaleY); // ... 绑定纹理、绘制

改完之后再测,平均帧率到了 48 帧,最低 33 帧。提升有,但还不够。GPU 调试工具显示绘制调用次数没变,说明瓶颈还在绘制调用和状态切换上。

4.3 第二步:纹理绑定缓存与状态排序

加纹理绑定缓存很简单,维护一个GLuint currentTexture变量,绑定前比较一下。状态排序稍微麻烦一点,需要在渲染前对对象列表排序。

排序的键我设计成一个 64 位整数:高 32 位是渲染层级,低 32 位是状态哈希(着色器 ID、纹理 ID、混合模式组合出来的)。这样一次排序就能同时保证遮挡正确和状态聚合。

uint64_t sortKey = (uint64_t(layer) << 32) | stateHash; std::sort(renderList.begin(), renderList.end(), [](const RenderItem& a, const RenderItem& b) { return a.sortKey < b.sortKey; });

这一步之后,纹理绑定降到了 30 次左右,状态切换降到了 40 次以下。帧率到了平均 53 帧,最低 38 帧。已经能感觉到明显流畅了,但离满帧还有距离。

4.4 第三步:顶点批处理的实现

批处理是重头戏。我实现了一个SpriteBatch类,核心是一个动态顶点数组和一个索引数组。每帧开始时清空,然后遍历排序后的渲染列表,把每个对象的四个顶点和六个索引追加进去。当遇到状态变化时,就把当前批次提交绘制,然后开始新批次。

顶点格式我用了位置(2 个 float)、UV(2 个 float)、颜色(4 个 unsigned byte)。颜色用 byte 而不是 float,是为了省带宽。一个顶点 20 字节,1000 个顶点才 20KB,对移动设备很友好。

struct Vertex { float x, y; float u, v; uint8_t r, g, b, a; };

提交批次的时候,用glBufferSubData更新顶点缓冲区和索引缓冲区,然后一次glDrawElements。索引缓冲区的好处是四个顶点可以复用,六个索引画两个三角形,比glDrawArrays的六个顶点省一点。

这里有个坑:glBufferSubData在有些驱动上如果频繁调用小数据量更新,性能不好。我的做法是攒够一批再更新,而不是每个对象更新一次。因为我们是按批次提交的,所以天然就是攒够一批更新一次,这个问题自然规避了。

批处理改完之后,绘制调用从 312 次降到了 8 次左右(因为还有几个不同状态的批次)。帧率直接到了平均 59 帧,最低 52 帧。基本满帧了。

4.5 参数选择与计算过程

批处理的缓冲区大小怎么定?我算了一下:最坏情况下,屏幕上可能有 500 个精灵,每个精灵 4 个顶点,就是 2000 个顶点。每个顶点 20 字节,顶点数据 40KB。索引每个精灵 6 个,共 3000 个索引,每个索引 2 字节(用 unsigned short),共 6KB。总共不到 50KB。这个量级对移动设备来说很小,所以我把缓冲区大小定在 4096 个顶点和 8192 个索引,留了一倍余量。

双缓冲的实现是准备两套缓冲区对象,用一个标志位交替使用。这样 GPU 在读上一帧的缓冲区时,CPU 可以写另一套,不会互相阻塞。

图集大小选 2048x2048,是因为 GLES2 规范要求最大纹理尺寸至少 2048,选这个值能保证所有设备都支持。如果设备支持更大的,可以动态查询GL_MAX_TEXTURE_SIZE再决定,但为了简单,固定 2048 最省事。

5. 常见问题与排查技巧实录

5.1 画面错乱与闪烁

批处理改完之后,最容易出现的问题是画面错乱,精灵位置不对或者闪烁。我遇到过一次,原因是顶点数据的写入顺序和索引的对应关系搞错了。批处理里每个精灵占 4 个顶点,索引是相对于批次起始位置的偏移,如果偏移算错,就会画到别的地方去。

排查方法:先把批处理关掉,退回逐个绘制,确认逻辑没问题。然后打开批处理,但每批只放一个精灵,确认单个精灵的顶点和索引正确。再逐步增加每批的精灵数,观察从第几个开始出错,就能定位到偏移计算的问题。

5.2 纹理边缘出现接缝

图集化之后,精灵边缘可能出现细线或者颜色渗漏。这是纹理采样时采到了相邻精灵的像素。解决办法是在图集里给每个精灵留 1 到 2 像素的 padding,或者在着色器里把 UV 往内缩一点点。

我用的方案是 padding 加 UV 内缩。padding 在打包图集时留,UV 内缩在计算 UV 矩形时做,缩的量是半个像素对应的 UV 值。这样双保险,基本不会出现接缝。

5.3 帧率不升反降

有时候改完批处理,帧率反而降了。这种情况通常是批次划分不合理,批次太多,每批的顶点太少,导致绘制调用的开销没有摊薄。或者是排序开销太大,超过了省下来的状态切换开销。

排查方法:统计每帧的批次数和每批的平均顶点数。如果批次数超过 20,或者每批平均顶点数低于 100,就说明批次划分有问题。检查排序键的设计,看看是不是状态哈希太细,导致相同状态的对象没有聚到一起。

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
画面错乱闪烁顶点索引偏移错误逐批减少精灵数定位检查索引计算,确保相对偏移正确
纹理边缘接缝采样越界放大图集查看边缘加 padding,UV 内缩半像素
帧率不升反降批次过多或排序开销大统计批次数和顶点数调整排序键,合并批次
内存占用升高缓冲区过大或双缓冲查看缓冲区分配按实际需求调整缓冲区大小
低端设备崩溃超出纹理尺寸限制查询 GL_MAX_TEXTURE_SIZE图集尺寸降到设备支持范围内

5.5 几个容易忽略的细节

第一个细节是glFlush和glFinish的使用。这两个调用会强制驱动执行命令,破坏流水线,能不用就不用。我见过有人在每帧结束调glFinish,帧率直接腰斩。除非确实需要同步,否则不要调。

第二个细节是着色器的 uniform 位置。每次glGetUniformLocation都有开销,应该在初始化时查一次,把位置存起来。这个虽然是小开销,但积少成多。

第三个细节是顶点属性的启用和禁用。glEnableVertexAttribArray和glDisableVertexAttribArray也是有开销的,如果属性数组在整个渲染过程中都启用,就不要每批都禁用再启用。我的做法是在初始化时启用,渲染结束后再禁用,中间不动。

第四个细节是混合模式的设置。glBlendFunc的调用开销比想象中大,尤其是当驱动需要重新计算混合状态的时候。把相同混合模式的对象排在一起,减少调用次数,效果很明显。

6. 优化效果验证与后续扩展方向

6.1 最终效果对比

改造完成后,我在同一台设备上跑了同样的测试场景。平均帧率从 42 帧提升到 59 帧,最低帧率从 28 帧提升到 52 帧。绘制调用从 312 次降到 8 次,纹理绑定从 318 次降到 6 次,uniform 上传从 900 多次降到 20 次以内。CPU 占用率也明显下降,因为省掉了大量的矩阵计算和 GL 调用。

在另一台更低端的设备上测试,原来只能跑 25 帧左右,优化后能稳定在 45 帧以上,已经可以正常游玩了。这说明优化对低端设备的收益更大,因为低端设备的 CPU 和 GPU 都更弱,省下来的每一分开销都更宝贵。

6.2 还能继续挖的地方

批处理做完之后,渲染路径上的大头基本都砍掉了。如果还想继续优化,有几个方向可以挖。

一个是把静态的、不变的对象预先合并成静态批次,比如背景的草坪、固定的装饰物,这些每帧都不变,可以预先合并好,每帧直接画,连顶点数据都不用重新写。这个能进一步省 CPU 时间。

另一个是考虑用纹理数组或者图集分页,减少纹理切换。不过 GLES2 不支持纹理数组,这个方向受限。

还有一个是优化排序算法。如果对象数量继续增长,排序可能成为新的瓶颈。可以考虑用计数排序或者桶排序,把 O(n log n) 降到 O(n)。不过对于 PvZ 这个规模,暂时没必要。

6.3 我个人在实际操作中的体会

做渲染优化这几年,我最大的体会是:先测量,再优化,不要凭感觉。我见过太多人一上来就改代码,改完发现没效果,甚至更慢。原因就是没搞清楚瓶颈在哪。GPU 调试工具、帧率统计、调用计数,这些手段一定要用起来。

第二个体会是:优化是有层次的,要按顺序来。先做低风险高收益的,比如缓存和状态排序,再做高风险的批处理。如果一上来就上批处理,出了问题很难定位,因为改动太大。

第三个体会是:移动端的优化和桌面端很不一样。移动 GPU 是 tile-based 的架构,对带宽和状态切换更敏感,对绘制调用的容忍度更低。在桌面上可能几百次绘制调用无所谓,在移动端就是灾难。所以移动端的优化要更激进地合并和缓存。

最后分享一个小技巧:如果你不确定某个优化有没有效果,可以做一个开关,运行时切换,然后对比帧率。这样比改来改去再回滚要高效得多。我在做批处理的时候就是这么干的,先加开关,确认有效果再默认打开,出问题也能快速关掉排查。

返回列表