# H5 游戏内存优化实战
把前面学的知识串起来,变成可落地的优化方案
前置知识:第01~08章全部内容
阅读指南(初学者必看)
为什么这是压轴章节?
前8章你学了理论:
- 浏览器渲染管线 → 知道合成层怎么吃显存
- 事件循环 → 知道长任务怎么导致掉帧
- 全链路内存 → 知道 GPU 显存才是大头
- 内存泄漏排查 → 知道怎么找到泄漏
- Node.js 内存 → 知道服务端怎么监控
本章把这些知识变成可落地的代码。
学完本章,你能回答:
- 纹理管理器怎么实现?LRU 策略怎么编码?
- 对象池怎么实现?什么时候用?
- 合批策略有哪些?怎么选?
- H5 游戏内存优化的完整检查清单
本文结构
第一部分:纹理管理策略(纹理生命周期管理器 + LRU 缓存)
第二部分:对象池模式(通用对象池 + 子弹池 + 粒子池)
第三部分:合批与渲染内存优化(纹理图集 + 动态/静态合批 + GPU Instancing)
第四部分:真实性能优化案例
第五部分:H5 游戏内存优化检查清单
一、纹理管理策略
纹理生命周期
纹理的完整生命周期:
1. 创建 → 加载图片数据到 CPU 内存
2. 上传 → texImage2D 上传到 GPU 显存
3. 使用 → 绑定纹理进行渲染
4. 缓存 → 在 LRU 缓存中保留
5. 淘汰 → 缓存满了,淘汰最久未用的纹理
6. 释放 → gl.deleteTexture() 释放 GPU 显存
7. 重加载 → 需要时重新从磁盘/网络加载
纹理生命周期管理器
/**
* 纹理生命周期管理器
*
* 核心功能:
* 1. LRU 缓存:纹理有上限,满了淘汰最久未用的
* 2. 手动释放:gl.deleteTexture() 释放 GPU 显存
* 3. 场景管理:场景切换时批量释放
* 4. 内存统计:实时显示纹理总显存占用
*/
class TextureManager {
constructor(gl, maxCacheSize = 100 * 1024 * 1024) { // 默认最大 100MB
this.gl = gl;
this.maxCacheSize = maxCacheSize;
this.currentSize = 0;
this.cache = new Map(); // name → { texture, size, lastUsed, width, height }
}
/**
* 加载纹理
* 如果缓存中存在,更新 lastUsed 并返回
* 如果缓存中不存在,创建纹理并加入缓存
*/
load(name, imageSource) {
// 缓存命中
if (this.cache.has(name)) {
const entry = this.cache.get(name);
entry.lastUsed = Date.now();
// LRU:移到末尾(Map 保持插入顺序)
this.cache.delete(name);
this.cache.set(name, entry);
return entry.texture;
}
// 创建纹理
const gl = this.gl;
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, imageSource);
// 设置纹理参数
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.generateMipmap(gl.TEXTURE_2D);
// 估算显存占用
const width = imageSource.width || imageSource.videoWidth;
const height = imageSource.height || imageSource.videoHeight;
const size = this.estimateTextureSize(width, height);
// 缓存满了?淘汰最久未用的
while (this.currentSize + size > this.maxCacheSize) {
const evicted = this.evictOldest();
if (!evicted) break; // 缓存空了,无法淘汰
}
// 加入缓存
const entry = { texture, size, lastUsed: Date.now(), width, height };
this.cache.set(name, entry);
this.currentSize += size;
console.log(`加载纹理: ${name}, ${width}×${height}, ${(size/1024/1024).toFixed(1)}MB, 总计: ${(this.currentSize/1024/1024).toFixed(1)}MB`);
return texture;
}
/**
* 淘汰最久未用的纹理
* 返回被淘汰的纹理名称,或 null(缓存为空)
*/
evictOldest() {
// Map 的迭代顺序就是插入顺序(LRU 顺序)
const firstKey = this.cache.keys().next().value;
if (!firstKey) return null;
const entry = this.cache.get(firstKey);
this.gl.deleteTexture(entry.texture); // ⭐ 必须手动释放GPU显存!
this.cache.delete(firstKey);
this.currentSize -= entry.size;
console.log(`淘汰纹理: ${firstKey}, 释放 ${(entry.size/1024/1024).toFixed(1)}MB`);
return firstKey;
}
/**
* 估算纹理显存占用
* RGBA8: width × height × 4 bytes
* Mipmap: 额外约 33%
*/
estimateTextureSize(width, height) {
return Math.ceil(width * height * 4 * 1.33);
}
/**
* 场景切换时批量释放
* 纹理名称以 sceneName 开头的全部释放
*/
releaseScene(sceneName) {
const toDelete = [];
for (const [name, entry] of this.cache) {
if (name.startsWith(sceneName + '/')) {
this.gl.deleteTexture(entry.texture);
this.currentSize -= entry.size;
toDelete.push(name);
}
}
for (const name of toDelete) {
this.cache.delete(name);
}
console.log(`释放场景纹理: ${sceneName}, ${toDelete.length}个, 当前总计: ${(this.currentSize/1024/1024).toFixed(1)}MB`);
}
/**
* 释放指定纹理
*/
release(name) {
if (!this.cache.has(name)) return;
const entry = this.cache.get(name);
this.gl.deleteTexture(entry.texture);
this.currentSize -= entry.size;
this.cache.delete(name);
console.log(`释放纹理: ${name}, 释放 ${(entry.size/1024/1024).toFixed(1)}MB`);
}
/**
* 获取当前缓存状态
*/
getStats() {
return {
count: this.cache.size,
totalSize: this.currentSize,
maxSize: this.maxCacheSize,
usagePercent: (this.currentSize / this.maxCacheSize * 100).toFixed(1) + '%'
};
}
/**
* 释放所有纹理
*/
releaseAll() {
for (const [, entry] of this.cache) {
this.gl.deleteTexture(entry.texture);
}
this.cache.clear();
this.currentSize = 0;
console.log('释放所有纹理');
}
}
// 使用示例
const texManager = new TextureManager(gl, 200 * 1024 * 1024); // 最大 200MB
// 加载纹理
const heroTex = texManager.load('hero/hero_idle', heroImage);
const bgTex = texManager.load('scene1/background', bgImage);
// 场景切换
texManager.releaseScene('scene1'); // 释放场景1的所有纹理
// 查看状态
console.log(texManager.getStats());
// { count: 1, totalSize: 1048576, maxSize: 209715200, usagePercent: '0.5%' }
二、对象池模式
为什么需要对象池?
生活类比:对象池就像"自行车租赁站"。需要自行车时租一辆,不用了还回去。不需要每次都买新的(创建对象),也不用扔掉(等GC回收)。
不用对象池:
帧1: 创建100颗子弹 → 100个新对象
帧2: 100颗子弹消失 → 100个对象变成垃圾
帧3: 创建100颗子弹 → 又100个新对象
→ 每帧产生100个短命对象 → 频繁 GC → 卡顿
用对象池:
初始化: 预创建100颗子弹
帧1: 从池中取100颗子弹 → 0个新对象
帧2: 100颗子弹还回池子 → 0个垃圾
帧3: 从池中取100颗子弹 → 0个新对象
→ 零 GC 压力 → 不卡顿
通用对象池实现
/**
* 通用对象池
*
* @param {Function} createFn - 创建对象的函数
* @param {Function} resetFn - 重置对象状态的函数
* @param {number} initialSize - 初始池大小
*/
class ObjectPool {
constructor(createFn, resetFn, initialSize = 50) {
this.createFn = createFn;
this.resetFn = resetFn;
this.pool = [];
this.activeCount = 0;
// 预创建对象
for (let i = 0; i < initialSize; i++) {
this.pool.push(createFn());
}
}
/**
* 从池中获取一个对象
*/
get() {
this.activeCount++;
if (this.pool.length > 0) {
const obj = this.pool.pop();
return obj;
}
// 池空了,创建新的
console.warn(`对象池扩容: 当前活跃 ${this.activeCount}`);
return this.createFn();
}
/**
* 将对象还回池中
*/
release(obj) {
this.activeCount--;
this.resetFn(obj); // 重置对象状态
this.pool.push(obj);
}
/**
* 预热池(提前创建对象)
*/
warmup(count) {
for (let i = 0; i < count; i++) {
this.pool.push(this.createFn());
}
}
/**
* 清空池
*/
clear() {
this.pool.length = 0;
this.activeCount = 0;
}
/**
* 获取池的状态
*/
getStats() {
return {
available: this.pool.length,
active: this.activeCount,
total: this.pool.length + this.activeCount
};
}
}
子弹对象池
// 子弹数据结构
const bulletPool = new ObjectPool(
// 创建函数
() => ({ x: 0, y: 0, vx: 0, vy: 0, damage: 0, active: false, type: '' }),
// 重置函数
(b) => { b.x = 0; b.y = 0; b.vx = 0; b.vy = 0; b.damage = 0; b.active = false; b.type = ''; },
// 初始大小
200
);
// 发射子弹
function shoot(x, y, direction, damage, type) {
const bullet = bulletPool.get();
bullet.x = x;
bullet.y = y;
bullet.vx = direction.x * 500;
bullet.vy = direction.y * 500;
bullet.damage = damage;
bullet.type = type;
bullet.active = true;
return bullet;
}
// 子弹消失后回收
function onBulletHit(bullet, target) {
target.takeDamage(bullet.damage);
bullet.active = false;
bulletPool.release(bullet); // 还回池子
}
// 子弹出界后回收
function onBulletOutOfBounds(bullet) {
bullet.active = false;
bulletPool.release(bullet);
}
// 游戏主循环
function update(dt) {
for (let i = activeBullets.length - 1; i >= 0; i--) {
const bullet = activeBullets[i];
bullet.x += bullet.vx * dt;
bullet.y += bullet.vy * dt;
if (isOutOfBounds(bullet)) {
onBulletOutOfBounds(bullet);
activeBullets.splice(i, 1);
}
}
}
// 场景切换时清空
function onSceneChange() {
bulletPool.clear();
}
粒子对象池
// 粒子数据结构
const particlePool = new ObjectPool(
() => ({
x: 0, y: 0,
vx: 0, vy: 0,
life: 0, maxLife: 0,
size: 0, alpha: 1,
color: { r: 255, g: 255, b: 255 },
active: false
}),
(p) => {
p.x = 0; p.y = 0;
p.vx = 0; p.vy = 0;
p.life = 0; p.maxLife = 0;
p.size = 0; p.alpha = 1;
p.color.r = 255; p.color.g = 255; p.color.b = 255;
p.active = false;
},
500
);
// 发射粒子
function emitParticles(x, y, count, config) {
for (let i = 0; i < count; i++) {
const particle = particlePool.get();
particle.x = x;
particle.y = y;
particle.vx = (Math.random() - 0.5) * config.spread;
particle.vy = (Math.random() - 0.5) * config.spread;
particle.maxLife = config.lifetime + Math.random() * config.lifetimeVariance;
particle.life = particle.maxLife;
particle.size = config.size + Math.random() * config.sizeVariance;
particle.color = { ...config.color };
particle.active = true;
activeParticles.push(particle);
}
}
// 更新粒子
function updateParticles(dt) {
for (let i = activeParticles.length - 1; i >= 0; i--) {
const p = activeParticles[i];
p.life -= dt;
if (p.life <= 0) {
p.active = false;
particlePool.release(p);
activeParticles.splice(i, 1);
continue;
}
p.x += p.vx * dt;
p.y += p.vy * dt;
p.alpha = p.life / p.maxLife;
p.size *= 0.99; // 缩小
}
}
什么时候用对象池?
| 对象特征 | 是否用对象池 | 原因 |
|---|---|---|
| 每帧创建销毁(子弹、粒子) | ✅ 用 | 避免频繁 GC |
| 偶尔创建(UI弹窗、配置) | ❌ 不用 | 开销可忽略 |
| 生命周期很长(玩家、NPC) | ❌ 不用 | 不会产生 GC 压力 |
| 数量巨大且频繁替换(掉落物) | ✅ 用 | 控制内存峰值 |
三、合批与渲染内存优化
优化策略全景
渲染内存优化策略(按效果从大到小排列):
1. 纹理图集(Texture Atlas)── 减少纹理切换 + 减少DrawCall
2. 纹理压缩 ── 减少 GPU 显存 4~8 倍
3. 动态合批 ── 减少 DrawCall
4. 静态合批 ── 减少 DrawCall(适合静态场景)
5. GPU Instancing ── 一次绘制多个相同物体
6. LOD ── 远处用低精度模型
7. 剔除 ── 不在视野内的不渲染
纹理图集(Texture Atlas)
什么是纹理图集?
把多张小图合成一张大图,减少纹理切换次数
没有图集:
┌─────┐ ┌─────┐ ┌─────┐
│图1 │ │图2 │ │图3 │ 3个纹理 = 3次绑定 = 3个DrawCall
└─────┘ └─────┘ └─────┘
有图集:
┌─────────────────┐
│ 图1 │ 图2 │ 图3 │ 1个纹理 = 1次绑定 = 1个DrawCall
│ │ │ │
└─────────────────┘
纹理图集的注意事项:
- 图集大小限制:最大 2048×2048(移动端)/ 4096×4096(PC端)
- 同一图集的精灵才能合批(不同图集 = 不同材质 = 不同DrawCall)
- 注意 Padding:精灵之间留 2px 间隔,避免纹理采样渗透
- Cocos Creator 自动支持:Sprite 自动打包到图集(Auto Atlas)
动态合批 vs 静态合批 vs GPU Instancing
| 动态合批 | 静态合批 | GPU Instancing | |
|---|---|---|---|
| 原理 | 运行时合并顶点数据 | 预先合并顶点数据 | 同一网格多次绘制 |
| 适用场景 | 相同材质的小物体 | 不变的静态场景 | 大量相同物体 |
| 限制 | 顶点数 < 300 | 合并后不可单独移动 | 必须同一网格+材质 |
| CPU开销 | 每帧合并(有开销) | 一次性合并(无开销) | 极低 |
| 内存开销 | 低 | 较高(合并后的顶点) | 极低 |
| 游戏例子 | UI元素、小精灵 | 地图、建筑 | 草地、树木、石块 |
Cocos Creator 的合批:
- Batcher2D 自动处理动态合批
- 条件:相同材质 + 顶点数 < 300 + 无中断(中间没有其他材质的节点)
- 中断合批的因素:不同图集、Spine动画、Mask组件、自定义材质
纹理压缩
纹理压缩对比:
| 格式 | 压缩比 | 适用平台 | 质量 |
|---|---|---|---|
| RGBA8(未压缩) | 1:1 | 全平台 | 无损 |
| ETC2 | 4:1 | Android/Web | 有损 |
| ASTC | 4:1~8:1 | 移动端 | 可调 |
| BCn (DXT) | 4:1 | 桌面端 | 有损 |
| PVRTC | 4:1~8:1 | iOS | 有损 |
实际效果: 一张 2048×2048 纹理
- RGBA8: 16MB
- ETC2: 4MB(4:1压缩)
- ASTC 6×6: 1.8MB(约9:1压缩)
一个游戏场景 50 张纹理
- 不压缩: 800MB 显存
- ETC2: 200MB 显存
- ASTC: 90MB 显存
Cocos Creator 构建时自动压缩: 项目设置 → 构建发布 → 纹理压缩 勾选对应平台的格式即可
四、真实性能优化案例
案例1:H5游戏加载大图后内存暴增
问题描述:某H5游戏在加载关卡资源后,内存从100MB暴增到500MB,导致低端手机闪退。
排查过程:
Memory曲线:
JS Heap: 平稳(100MB -> 120MB)
DOM Nodes: 平稳
GPU Memory: 暴涨(50MB -> 400MB)
结论:GPU显存泄漏或过量分配
问题代码:
class AssetLoader {
loadLevel(levelId) {
const textures = ['bg.png', 'hero.png', 'enemy.png', 'ui.png'];
textures.forEach(name => {
const img = new Image();
img.src = `assets/level${levelId}/${name}`;
// 图片加载后没有管理,永远留在内存中!
});
}
}
修复代码:
class AssetLoader {
constructor(gl) {
this.gl = gl;
this.textures = new Map(); // 管理所有纹理
}
loadLevel(levelId) {
// 1. 释放上一关的资源
this.disposeCurrentLevel();
// 2. 加载新资源
const textures = ['bg.png', 'hero.png', 'enemy.png', 'ui.png'];
textures.forEach(name => {
this.loadTexture(`assets/level${levelId}/${name}`, name);
});
}
loadTexture(src, name) {
const gl = this.gl;
const texture = gl.createTexture();
const img = new Image();
img.onload = () => {
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
// 图片数据上传到GPU后,可以释放Image对象
img.src = ''; // 释放图片解码缓冲区
};
img.src = src;
this.textures.set(name, texture);
}
disposeCurrentLevel() {
this.textures.forEach((texture, name) => {
this.gl.deleteTexture(texture); // 显式删除GPU纹理
});
this.textures.clear();
}
}
验证结果:
优化前:
关卡1内存:100MB
关卡2内存:300MB
关卡3内存:500MB(闪退!)
优化后:
关卡1内存:100MB
关卡2内存:110MB
关卡3内存:105MB
内存稳定在基线水平,问题解决!
案例2:DOM操作导致游戏帧率暴跌
问题描述:某H5游戏在更新UI分数时,帧率从60fps暴跌到20fps。
排查过程:
Main线程火焰图:
|████████████████████████████████████████| <- 长任务45ms!
|
+-- updateScore() (43ms)
|
+-- document.getElementById('score').textContent = newScore (40ms)
|
+-- Recalculate Style (20ms)
+-- Layout (15ms)
+-- Paint (5ms)
问题:每次更新分数都触发了完整的样式计算+布局+绘制。
修复方案:
#score {
position: absolute;
font-size: 24px;
width: 100px; /* 固定宽度,避免布局计算 */
text-align: right;
/* 使用transform做动画,不触发重排 */
transition: transform 0.1s;
}
#score.pop {
transform: scale(1.2);
}
// 优化前
document.getElementById('score').textContent = newScore;
// 优化后
const scoreEl = document.getElementById('score');
scoreEl.textContent = newScore;
// 添加缩放动画(只触发合成,不触发重排)
scoreEl.classList.add('pop');
setTimeout(() => scoreEl.classList.remove('pop'), 100);
验证结果:
优化前:updateScore耗时:40ms -> 帧率降到25fps
优化后:updateScore耗时:2ms -> 帧率保持60fps
五、H5 游戏内存优化检查清单
┌─────────────────────────────────────────────────────────┐
│ H5 游戏内存优化检查清单 │
├─────────────────────────────────────────────────────────┤
│ │
│ □ 纹理管理 │
│ □ 所有纹理有释放机制 │
│ □ WebGL: gl.deleteTexture() │
│ □ Cocos: cc.assetManager.releaseAsset() │
│ □ 使用纹理图集减少 DrawCall │
│ □ 使用纹理压缩减少显存(ETC2/ASTC) │
│ □ 场景切换时释放不用的纹理 │
│ □ 纹理缓存有上限(LRU) │
│ □ 不在视野内的纹理可以释放 │
│ │
│ □ 对象池 │
│ □ 频繁创建销毁的对象使用对象池 │
│ □ 子弹 / 投射物 │
│ □ 粒子效果 │
│ □ 怪物 / 掉落物 │
│ □ 伤害数字 │
│ □ 对象池预分配合理大小 │
│ □ 场景切换时清空对象池 │
│ │
│ □ DOM / 事件 │
│ □ 移除 DOM 元素前先清除 JS 引用 │
│ □ 移除事件监听器(removeEventListener) │
│ □ 清除定时器(clearInterval/clearTimeout) │
│ □ 闭包不持有大对象引用 │
│ □ 不产生 Detached DOM │
│ │
│ □ 合成层(DOM 游戏) │
│ □ 检查合成层数量(< 30个) │
│ □ 移除不必要的 will-change │
│ □ 动画结束后清理合成层 │
│ □ 用 z-index 隔离层叠上下文 │
│ │
│ □ 监控 │
│ □ 实时监控 JS Heap 大小 │
│ □ 实时监控 FPS │
│ □ 定期检测 Detached DOM │
│ □ 内存超过阈值告警 │
│ □ 上线前做长时间运行测试(>4小时) │
│ │
└─────────────────────────────────────────────────────────┘
自问自答
Q:纹理管理器的 LRU 缓存大小设多少合适?
A:取决于目标设备的内存。建议:低端手机(2GB RAM)设 50100MB,中端(4GB)设 100200MB,高端(6GB+)设 200~300MB。可以通过 navigator.deviceMemory 检测。
Q:对象池会不会导致内存浪费? A:会的。对象池预分配了对象,即使不用也占内存。解决:1) 根据实际用量设初始大小;2) 长时间不用时可以缩减池大小;3) 场景切换时清空池。
Q:纹理图集多大合适? A:移动端建议 2048×2048(约 16MB 未压缩),PC 端可以 4096×4096。太大会导致加载慢和显存浪费,太小会增加 DrawCall。
Q:Cocos Creator 的 releaseUnusedAssets 安全吗?
A:安全,它只释放引用计数为0的资源。但注意:如果你手动持有了资源的引用(如存在全局变量中),引用计数不会归零,资源不会被释放。
实践任务
- 任务1:实现纹理管理器(LRU + 主动释放),并用 Chrome Task Manager 验证释放效果
- 任务2:为游戏中的子弹/粒子实现对象池,对比有无对象池的 GC 频率
- 任务3:用 Chrome DevTools Memory 面板验证纹理释放后显存下降
- 任务4:实现内存监控看板(FPS + JS Heap + 纹理数量 + 节点数量)
- 任务5:对游戏做完整的内存优化检查清单审查,记录优化前后对比数据
与其他章节的关联
| 本章内容 | 关联章节 | 关联点 |
|---|---|---|
| 纹理管理器 | 第06章 | 理解 JS 堆↔GPU 显存桥梁才能正确实现 |
| 对象池 | 第04章 | 对象池减少 GC 停顿,避免长任务掉帧 |
| 合批策略 | 2_2_h5-rendering-mastery | DrawCall 优化在渲染项目中有详细讲解 |
| 泄漏修复 | 第07章 | 检查清单中的泄漏预防项来自第07章 |
| Cocos 优化 | 2_2_h5-rendering-mastery | Cocos 渲染架构在渲染项目中有详解 |
上一章:08-Node.js服务端内存 下一章:10-性能预算与监控体系