ARTICLE DETAIL

资讯详情

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

移动端2D游戏渲染优化:GLES2批处理与状态缓存实战

移动端2D游戏渲染优化:GLES2批处理与状态缓存实战 1. 从一次帧率骤降说起PvZ-Portable 渲染路径到底卡在哪把《植物大战僵尸》移植到移动端这件事听起来像是个周末小项目真动手才知道坑有多深。PvZ-Portable 这个项目本身已经跑通了核心玩法——向日葵产阳光、豌豆射手吐子弹、僵尸一波波涌进来逻辑层用 Julia 写渲染层走 GLES2/OpenGL 这条经典管线。但真正上手玩几分钟就会发现中低端安卓机上帧率会从 60 掉到 30 甚至更低而且掉帧的位置很有规律僵尸数量一多、子弹满天飞、阳光不断弹出的时候画面就开始发涩。这个现象非常典型。很多人第一反应是手机 GPU 不行但实测下来同样一台机器跑其他 2D 游戏毫无压力。问题不在硬件而在渲染路径上做了大量重复工作。所谓渲染路径简单说就是从游戏逻辑决定这一帧要画什么到GPU 真正把像素涂到屏幕上之间经过的所有环节。PvZ-Portable 早期版本里这条路径上存在几类明显的浪费同一张贴图被反复绑定、同一批顶点数据每帧重新上传、状态切换没有做任何合并、透明混合开着却画了大量完全被遮挡的精灵。我拿一台骁龙 660 的老机器做过对比测试僵尸波次进行到第 10 波时逻辑层耗时大约 4ms而渲染层耗时飙到 22ms其中光是glBindTexture和glDrawArrays的调用次数就超过 800 次。这个数字意味着什么意味着平均每个精灵都在单独走一遍完整的绘制流程GPU 大部分时间不是在画像素而是在等 CPU 把命令一条条喂过来。所以这篇内容想聊的不是怎么把游戏跑起来而是怎么把渲染路径上那些重复的、可以合并的、根本没必要做的工作砍掉。适合两类人看一类是正在做 2D 手游移植、被帧率折磨的开发者另一类是想理解 GLES2 渲染管线优化思路、但不想啃厚厚一本图形学教材的实践派。我会把每一步的动机、具体做法、实测数据都摊开讲你能直接抄作业也能理解为什么这么抄。2. 先搞清楚 GLES2 在移动端的真实脾气2.1 GLES2 不是缩水版 OpenGL它有自己的性格很多人从桌面 OpenGL 转过来做移动端会下意识觉得 GLES2 就是砍掉了一些高级特性的 OpenGL。这个理解会害死人。GLES2 的设计哲学是为低功耗、窄带宽、弱 CPU 的场景服务它没有几何着色器、没有实例化绘制的原生支持需要扩展、没有统一的顶点数组对象VAO 在 GLES2 里不是核心特性这些限制直接决定了你的优化策略。最要命的一点是GLES2 的驱动实现质量参差不齐。桌面显卡驱动你基本可以信任但移动端 GPU 厂商Mali、Adreno、PowerVR的驱动里藏着各种如果这样调用我就特别慢的暗规则。比如在某些 Mali 驱动上频繁切换glBlendFunc会导致管线刷新代价比切换纹理还高。这些不是文档里写的是踩出来的。PvZ-Portable 用的是 Julia 生态里的 OpenGL 绑定底层还是标准的 GLES2 调用。Julia 本身有 GC如果每帧在渲染循环里创建临时数组、字符串或者闭包GC 压力会直接反映到帧率抖动上。这一点和纯 C 项目完全不同必须专门处理。2.2 渲染路径上的重复工作具体指什么我把 PvZ-Portable 渲染一帧的流程拆开标出重复发生的地方环节早期做法重复问题纹理绑定每个精灵单独glBindTexture同一图集内精灵反复绑定同一纹理顶点数据每个精灵单独上传 4 个顶点每帧上传数千次小数据块状态设置每个精灵设置混合、着色器状态切换无缓存、无排序绘制调用每个精灵一次glDrawArraysDrawCall 数量爆炸矩阵计算每个精灵单独算 MVP相同变换重复计算这张表里的每一行都是可以优化的空间。核心思路就一句话把能合并的合并把能缓存的缓存把能预计算的预计算。听起来简单但每一步都有坑。2.3 为什么减少重复比提升单次效率更划算假设你有一个函数单次执行需要 1 微秒。你有两个选择把它优化到 0.5 微秒或者把调用次数从 1000 次降到 100 次。前者需要深入底层、改算法、可能引入 bug后者只需要改变调用方式风险低、收益大。在移动端渲染里这个逻辑尤其成立。因为移动端 GPU 的瓶颈往往不在算得慢而在CPU 喂命令喂不过来和带宽不够用。减少重复工作直接命中这两个瓶颈。PvZ-Portable 的优化实践里DrawCall 从 800 降到 60 左右帧率立刻从 30 稳定到 55 以上逻辑层代码一行没改。3. 纹理与图集把绑定次数从八百降到个位数3.1 纹理绑定的真实代价glBindTexture这个调用在桌面端看起来人畜无害但在移动端是有实打实开销的。每次绑定驱动需要检查纹理状态、更新硬件寄存器、可能触发缓存失效。如果一帧内绑定几百次累积起来就是几毫秒。PvZ-Portable 早期把每个植物、每个僵尸、每个子弹都做成独立纹理。阳光是一种纹理豌豆是一种纹理普通僵尸、路障僵尸、铁桶僵尸各是一种纹理。渲染时按精灵逐个绑定一帧下来绑定次数轻松破五百。优化方向很明确图集Texture Atlas。把所有小图打包到一张大图上渲染时只需要绑定一次后续所有精灵都从这张图集里采样。这是 2D 游戏最经典也最有效的优化手段。3.2 图集打包的具体做法与 Julia 侧实现打包图集本身可以用现成工具但 PvZ-Portable 的情况特殊资源是动态加载的不同关卡用到的精灵集合不同。所以我采用的是按关卡预打包 运行时缓存的策略。具体步骤扫描当前关卡所有精灵资源收集它们的尺寸。用简单的 shelf/skyline 装箱算法排布到 2048x2048 的图集上2048 是 GLES2 保证支持的最小最大纹理尺寸兼容性最好。记录每个精灵在图集中的 UV 坐标存成查找表。运行时渲染精灵时从查找表取 UV而不是从独立纹理取。Julia 侧的关键是避免在渲染循环里做字典查找。我预先把查找表转成连续内存的数组用精灵 ID 直接索引# 预计算阶段把 UV 查找表转成扁平数组 # uv_table[精灵ID*4 0..3] u0, v0, u1, v1 const UV_TABLE zeros(Float32, MAX_SPRITE_ID * 4) function get_uv(sprite_id::Int) base sprite_id * 4 return UV_TABLE[base1], UV_TABLE[base2], UV_TABLE[base3], UV_TABLE[base4] end这个改动看起来微不足道但实测下来光是消除字典查找和临时元组分配每帧就省了大约 1.5ms 的 CPU 时间。Julia 的 GC 在渲染循环里是隐形杀手任何在循环内创建的对象都会累积成停顿。注意图集尺寸不要盲目上 4096。虽然很多设备支持但低端机采样 4096 纹理的缓存命中率反而下降。2048 是移动端 2D 游戏的甜点值。3.3 图集带来的新问题透明边缘渗色打包图集后一个经典问题会出现精灵边缘采样到相邻精灵的像素出现彩色描边。这是因为线性过滤在 UV 边界外采样了。解决办法有两个一是图集打包时在精灵之间留 2 像素 padding二是把 UV 坐标向内收缩半个像素。我两个都做了因为 PvZ 的精灵很多是带透明通道的植物和僵尸边缘渗色特别明显。# UV 内缩半个像素避免采样到邻居 const HALF_PIXEL 0.5 / 2048.0 u0 HALF_PIXEL; v0 HALF_PIXEL u1 - HALF_PIXEL; v1 - HALF_PIXEL这个细节在文档里基本不会提但不做的话图集优化反而会让画面变脏。4. 批处理与 DrawCall 合并让 GPU 一次吃饱4.1 为什么 DrawCall 是移动端的头号敌人每一次glDrawArrays或glDrawElementsCPU 都要把命令打包、提交给驱动、驱动再转成 GPU 能执行的指令。这个过程有固定开销移动端尤其明显。业界经验值是移动端每帧 DrawCall 控制在 100 以内比较舒服超过 200 就开始有压力PvZ-Portable 早期是 800属于严重超标。批处理的核心思想是把多个精灵的顶点数据合并到一个大缓冲区里用一次 DrawCall 画出来。前提是这些精灵用同一张纹理、同一套渲染状态。图集优化已经解决了纹理统一的问题接下来就是顶点合并。4.2 动态顶点缓冲区的设计与实现GLES2 里没有 VAO 的核心支持所以每次绘制都要重新指定顶点属性指针。如果每个精灵单独画就是指定指针 → 画 → 指定指针 → 画的循环。批处理要做的是把所有精灵的顶点先写进一个 CPU 侧的大数组然后一次性上传、一次性指定指针、一次性画。PvZ-Portable 的实现里我维护了一个每帧复用的顶点缓冲区# 预分配避免每帧重新分配内存 const MAX_QUADS 2000 const VERTEX_DATA zeros(Float32, MAX_QUADS * 4 * 4) # 每顶点 x,y,u,v const INDEX_DATA zeros(UInt16, MAX_QUADS * 6) # 每 quad 两个三角形 # 每帧重置计数器而不是重新分配 mutable struct BatchState quad_count::Int current_texture::UInt32 end关键点在于复用。Julia 里如果每帧zeros(...)创建新数组GC 会疯掉。预分配 计数器重置是标准做法。索引缓冲区也是预生成的因为 quad 的索引模式是固定的0,1,2, 2,3,0只需要在初始化时生成一次。4.3 批处理中断的处理什么时候必须 flush批处理不是万能的。当遇到以下情况时必须先把当前批次画掉flush再开始新批次纹理变了换了一张图集混合模式变了比如从普通混合切到叠加混合着色器程序变了需要按特定顺序绘制比如 UI 覆盖在游戏画面之上PvZ-Portable 里我把渲染对象按纹理 混合模式排序让相同状态的精灵连续排列最大化批次长度。排序本身有开销但相比省下的 DrawCall完全值得。# 按 (texture_id, blend_mode) 排序渲染队列 sort!(render_queue, by x - (x.texture_id, x.blend_mode))实测数据优化前 800 DrawCall优化后稳定在 55-70 之间。帧率从 30 提升到 55而且帧生成时间更稳定不再有突然的卡顿。提示排序会改变绘制顺序如果游戏依赖绘制顺序实现遮挡关系比如后画的盖住先画的需要引入 z 值或分层排序。PvZ 的精灵层级比较简单我用了 layer 字段做二级排序键。5. 状态缓存与冗余调用消除省下那些看不见的开销5.1 状态切换的隐藏成本OpenGL 是一个状态机。你设置的每一个状态混合开关、深度测试、剔除模式、着色器程序都会一直生效直到你改变它。问题在于很多代码会无脑地每画一个东西就设置一遍状态哪怕状态根本没变。glEnable(GL_BLEND)连续调用 500 次和调用 1 次效果一样但开销差 500 倍。驱动内部虽然可能做了去重但你不能指望它。PvZ-Portable 早期代码里每个精灵绘制前都设置一遍混合和着色器这是典型的冗余。5.2 状态缓存层的设计解决办法是加一层状态缓存记录当前 GPU 的实际状态每次设置前先比较相同就跳过。mutable struct GLStateCache blend_enabled::Bool current_program::UInt32 current_texture::UInt32 blend_src::UInt32 blend_dst::UInt32 end function set_blend(cache::GLStateCache, enabled::Bool) if cache.blend_enabled ! enabled enabled ? glEnable(GL_BLEND) : glDisable(GL_BLEND) cache.blend_enabled enabled end end这个缓存层看起来简单但它是整个优化里性价比最高的改动之一。代码量不大风险低收益立竿见影。实测下来状态设置相关的 CPU 时间从每帧 3ms 降到 0.3ms。5.3 着色器程序的切换优化PvZ-Portable 用了几个不同的着色器普通精灵渲染、颜色叠加僵尸被冰冻时的蓝色 tint、灰度僵尸死亡时的效果。早期每个精灵绘制前都glUseProgram切换频繁。优化后我把使用相同着色器的精灵排在一起并且用状态缓存避免重复glUseProgram。更进一步我把颜色叠加和灰度做成着色器的 uniform 参数而不是独立的着色器程序。这样整个游戏只需要一个着色器程序彻底消除了程序切换。// 统一着色器通过 uniform 控制效果 uniform int u_effect; // 0普通, 1颜色叠加, 2灰度 uniform vec4 u_tint_color;这个改动让着色器切换次数从每帧几百次降到零。代价是着色器里多了一个分支但移动端 GPU 对这种简单分支的处理完全可以接受。6. 顶点数据与矩阵计算把 CPU 从重复劳动里解放出来6.1 每帧重新上传顶点数据的代价早期实现里每个精灵的 4 个顶点位置是在 CPU 侧根据精灵的世界坐标、尺寸、旋转角度实时计算的然后每帧上传。这意味着每帧要处理几千个顶点的浮点运算和内存拷贝。优化思路能预计算的预计算能移到 GPU 的移到 GPU。对于静态精灵比如背景、固定位置的 UI顶点位置完全不变可以一次性上传后续帧直接复用。对于动态精灵移动的僵尸、飞行的子弹位置每帧变但变化是有规律的——它们只是平移不需要重新计算每个顶点的绝对位置。6.2 用 uniform 传递变换而不是重算顶点更优雅的做法是顶点缓冲区里存精灵的局部坐标比如一个单位 quad 的四个角然后通过 uniform 传递每个精灵的世界变换矩阵。这样顶点数据永远不变只需要更新 uniform。但这里有个权衡每个精灵一次 uniform 更新 一次 DrawCall又回到了 DrawCall 爆炸的老路。所以最终方案是批处理 顶点预计算的结合对于同一批次的精灵在 CPU 侧一次性计算好所有顶点的最终位置写入大缓冲区。矩阵计算用 SIMD 友好的方式批量做避免逐个精灵调用矩阵乘法函数。# 批量计算 quad 顶点避免函数调用开销 inbounds for i in 1:quad_count x positions[i*21]; y positions[i*22] w sizes[i*21]; h sizes[i*22] base (i-1) * 16 VERTEX_DATA[base1] x; VERTEX_DATA[base2] y VERTEX_DATA[base3] xw; VERTEX_DATA[base4] y VERTEX_DATA[base5] xw; VERTEX_DATA[base6] yh VERTEX_DATA[base7] x; VERTEX_DATA[base8] yh # UV 从预计算表取 ... endinbounds去掉边界检查inbounds在渲染热路径上是必须的。Julia 的边界检查在单次调用里微不足道但每帧几万次累积起来就是几毫秒。6.3 避免 Julia GC 在渲染循环里捣乱Julia 的 GC 是分代的但渲染循环里如果持续分配短生命周期对象会频繁触发 minor GC。表现就是帧率周期性抖动。我的做法是所有渲染相关的数组预分配用计数器管理不动态增长。避免在循环里创建元组、数组、字符串。用views避免数组切片产生拷贝。关键循环用inbounds和类型稳定的函数。# 反面教材每帧创建新数组 for sprite in sprites verts compute_vertices(sprite) # 分配 upload(verts) end # 正确做法写入预分配缓冲区 for (i, sprite) in enumerate(sprites) write_vertices!(VERTEX_DATA, i, sprite) # 无分配 end实测下来消除渲染循环内的分配后帧率抖动从 ±15fps 降到 ±3fps体感流畅度提升非常明显。7. 实测数据与踩坑记录优化不是纸上谈兵7.1 优化前后的硬指标对比我在三台设备上做了完整测试每台设备跑同一关卡第 10 波僵尸记录平均帧率和 1% low 帧率设备优化前平均帧率优化后平均帧率优化前 1% low优化后 1% low骁龙 66028561248骁龙 85545602558天玑 120052603559DrawCall 从 800 降到 60 左右纹理绑定从 500 降到 5 以内状态切换从 1500 降到 20 以内。这些数字背后是渲染路径上大量重复工作被消除的直接结果。7.2 踩过的坑图集打包后的 UV 错位第一次做图集时我用了现成的打包工具但没注意它输出的 UV 是像素坐标还是归一化坐标。结果渲染出来所有精灵都错位画面像被打碎的拼图。排查过程先确认顶点位置没问题用纯色 quad 测试再确认纹理绑定没问题单独渲染图集本身最后定位到 UV 坐标。打包工具输出的是像素坐标而 GLES2 需要归一化坐标0.0 到 1.0。除以图集尺寸就好了但这个 bug 花了我两个小时。经验图集相关的 bug90% 出在 UV 上。先验证 UV再怀疑其他。7.3 踩过的坑批处理排序导致的层级错乱批处理需要按纹理和状态排序但排序会打乱绘制顺序。PvZ 里阳光应该显示在植物上方僵尸应该显示在背景上方。排序后这些层级关系乱了阳光被植物盖住。解决办法是引入 layer 字段排序时先按 layer 再按纹理sort!(render_queue, by x - (x.layer, x.texture_id, x.blend_mode))layer 值在游戏逻辑里指定背景 0植物 1僵尸 2子弹 3阳光 4UI 5。这样既保证了批处理效率又维持了正确的遮挡关系。7.4 踩过的坑Julia 的全局变量性能陷阱Julia 里全局变量是类型不稳定的在渲染循环里访问全局变量会导致严重的性能问题。我早期把纹理 ID、着色器 ID 都存在全局变量里结果每次访问都有动态类型查找。解决办法是用const声明或者把状态封装在结构体里通过参数传递。const全局变量在 Julia 里是类型稳定的性能没问题。# 慢非 const 全局变量 texture_id 0 # 类型不稳定 # 快const 全局变量 const TEXTURE_ID Ref{UInt32}(0)这个坑很隐蔽因为代码逻辑完全正确只是慢。用code_warntype或者性能分析工具才能发现。8. 还能往哪走几个值得尝试的进阶方向8.1 实例化绘制的可行性GLES2 核心不支持实例化绘制但ANGLE_instanced_arrays扩展在很多设备上可用。如果目标设备普遍支持可以把批处理进一步升级为实例化绘制顶点数据只上传一次每个精灵的变换通过实例属性传递。这能进一步降低 CPU 侧的顶点计算开销。不过兼容性是问题。我的策略是运行时检测扩展支持就用不支持就回退到当前方案。两条路径共用同一套渲染队列只是提交方式不同。8.2 纹理压缩格式的选择PvZ 的精灵是带透明通道的ETC1 不支持透明ETC2 支持但需要 GLES3。在 GLES2 设备上可以用 ETC1 单独的 alpha 纹理或者直接用未压缩的 RGBA8888。未压缩纹理占带宽但兼容性最好。如果目标设备以中高端为主可以考虑 ETC2。实测下来ETC2 能把纹理带宽降低到 RGBA8888 的四分之一对填充率敏感的场面比如大量半透明阳光提升明显。8.3 渲染线程与逻辑线程的分离目前 PvZ-Portable 的逻辑和渲染在同一线程逻辑帧的波动会直接影响渲染帧。如果能把渲染放到独立线程用双缓冲的命令队列通信可以进一步稳定帧率。但这涉及 Julia 的多线程编程复杂度较高需要仔细处理数据竞争。我目前的做法是逻辑层做时间切片保证每帧逻辑耗时可控暂时没上多线程。如果后续僵尸数量继续增加这会是一个值得投入的方向。8.4 用性能分析工具定位下一个瓶颈优化到当前程度后帧率已经比较稳定但偶尔还有小卡顿。下一步我打算用 GPU 调试工具比如 Adreno GPU Profiler、Mali Graphics Debugger抓取实际的 GPU 耗时分布看看是顶点处理、光栅化还是纹理采样成为新瓶颈。CPU 侧则用 Julia 的Profile模块确认没有遗漏的热点。优化是个持续过程没有做完的时候。但只要掌握了减少重复工作这个核心思路每发现一个重复点就多一分性能余量。PvZ-Portable 从 30 帧到 60 帧的过程本质上就是不断问自己这件事真的需要每帧都做吗的过程。
返回列表