游戏性能优化
概述
游戏的性能瓶颈通常集中在三处:渲染(Draw Call 过多、overdraw 严重)、CPU 逻辑(脚本执行、GC 停顿)、加载(资源读取与解析)。优化的通用思路是:先测量,再定位,最后优化——用性能面板(Chrome DevTools Performance)、帧率计数器、内存快照找出真正的热点,而不是盲目套用技巧。
| 优化维度 | 主要手段 | 收益 |
|---|---|---|
| Draw Call | 批处理、纹理图集、离屏缓存 | 渲染耗时显著下降 |
| 内存管理 | 对象池、避免频繁分配 | GC 停顿减少、帧率更稳 |
| 加载优化 | 懒加载、预加载、压缩 | 白屏/等待时间缩短 |
Draw Call 优化
什么是 Draw Call
每次调用 drawImage() / WebGL 的 drawElements() 都算一次 Draw Call。GPU 每次渲染指令都要经历"上传状态 → 提交顶点 → 光栅化"的流程,绘制指令之间的状态切换(换纹理、换 shader)是最大的开销来源。几十个物体各画一次,性能可能远差于几百个物体合并成一次绘制。
三种核心手段
1. 批处理(Batching)——把同纹理的多个绘制请求合并成一次:
// 反例:每个精灵单独绘制,产生 N 次 Draw Call
for (const s of sprites) ctx.drawImage(s.img, s.x, s.y)
// 正例:同类纹理收集后统一绘制,Canvas 2D 内部自动合并为少量绘制指令
spritesByTexture.forEach((list, img) => {
for (const s of list) ctx.drawImage(img, s.x, s.y)
})2. 纹理图集(Texture Atlas)——把多张小图拼成一张大图,让不同物体共用同一纹理,从而能被合并进同一个批次。这是精灵表(Sprite Sheet)的进阶用途。
3. 离屏缓存(Offscreen Cache)——静态内容(背景、复杂装饰)只画一次到离屏 Canvas,之后整块 drawImage 贴回,避免每帧重复绘制大量路径:
const cache = document.createElement('canvas')
// 复杂背景只构建一次
drawBackground(cache.getContext('2d'))
// 每帧只需一次 drawImage
ctx.drawImage(cache, 0, 0)Canvas 2D 状态机陷阱
Canvas 的 fillStyle / shadowBlur / filter 等是全局状态,切换它们会打断内部批处理。优化要点:
- 尽量避免每帧修改 shadowBlur / filter(costly)
- 相同样式的绘制尽量集中在一起
- 关闭不需要的阴影、全局透明度,保持简单的绘制路径
内存管理与对象池
GC 停顿问题
JavaScript 的垃圾回收(GC)会在回收对象时暂停主线程(Stop The World)。游戏逻辑每帧分配大量临时对象(子弹、粒子、字符串拼接),会导致 GC 频繁触发、帧率突然掉到十几帧。对策是减少分配、提高对象复用率。
对象池模式
把高频创建销毁的对象放进池子循环使用:需要时 acquire(),用完后 release() 归还而不是销毁。池子里的对象只是状态被重置,不再触发 GC 回收。
下面的基准测试演示了直接 new 与对象池复用在 30 轮 × 2000 个粒子下的耗时差异:
注意:对象少、创建频率低时两者差距很小(测试中可能相差无几),对象池的收益在持续高频创建/销毁的场景(弹幕、粒子、敌人刷新)中才明显——这时它省下的是 GC 的停顿时间,而不是表面上的分配时间。
其他内存策略
- 避免大对象频繁分配:大数组、长字符串尽量复用缓冲区
- 谨慎使用闭包与事件监听:每帧新建闭包会持续分配
- 用
const/无引用回收:及时置 null,让对象尽早进入可回收状态
加载优化
懒加载与预加载
| 策略 | 做法 | 场景 |
|---|---|---|
| 懒加载 | 用到时才加载资源 | 大型地图的远区块、后续关卡 |
| 预加载 | 进入前提前加载关键资源 | 战斗前的角色与特效 |
| 进度加载 | 加载时显示进度条,分块进入 | 首屏、切换关卡 |
// 预加载:资源都就绪后再启动游戏
const loader = new AssetLoader()
loader.add('bgm', 'audio/bgm.mp3')
loader.add('sprite', 'img/hero.png')
loader.load(() => game.start()) // 全部加载完成回调资源体积控制
- 纹理压缩:WebP / ASTC 等比 PNG 小数倍,透明图注意保留 alpha
- 音频压缩:BGM 用有损格式,音效尽量短;远程资源用 streaming
- 分包加载:Web 端把资源按关卡/章节拆成多个包,只加载当前需要的内容
- 缓存复用:HTTP 缓存 + 版本号,避免重复下载
运行时性能监控
在游戏里内置一个轻量帧率面板(FPS + 每帧 update/render 耗时),是定位"哪一帧卡了"最直接的手段:
let frames = 0, last = performance.now()
setInterval(() => {
const fps = frames * 1000 / (performance.now() - last)
last = performance.now(); frames = 0
hud.textContent = fps.toFixed(0) + ' FPS'
}, 1000)优化流程小结
- 测量:DevTools Performance 录制、帧率面板,确认瓶颈在渲染还是逻辑
- 定位:火焰图找耗时函数,Memory 面板看 GC 频率与堆增长
- 优化:渲染瓶颈 → 批处理/图集/缓存;逻辑瓶颈 → 对象池/降频/简化算法
- 复测:优化后重新测量,确认收益并防止回退
性能优化是"持续测量驱动"的循环,任何一次优化都应以可量化的数据(帧率、耗时、内存)作为判断依据。