# 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
│      │      │     │
└─────────────────┘

纹理图集的注意事项:

  1. 图集大小限制:最大 2048×2048(移动端)/ 4096×4096(PC端)
  2. 同一图集的精灵才能合批(不同图集 = 不同材质 = 不同DrawCall)
  3. 注意 Padding:精灵之间留 2px 间隔,避免纹理采样渗透
  4. 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-性能预算与监控体系