JS堆与GPU显存桥梁

深入理解 Image 对象、Canvas、WebGL 资源的内存链路,掌握纹理生命周期管理和显式释放方法

前置知识:第05章(浏览器全链路内存全景图)+ 2_2_h5-rendering-mastery(GPU 渲染)


阅读指南(初学者必看)

为什么你需要学习这个?

如果你是一名 H5 游戏/前端开发者,你可能会问:"浏览器会自动管理 GPU 显存,我为什么还要学纹理生命周期?"

答案是:GPU 显存不会自动释放!不懂纹理生命周期,你的游戏加载几张图片后显存就会暴涨,移动端会发热、闪退!

具体场景:

  • 游戏图片多,显存占用巨大
  • 切换场景后显存没释放
  • 低端设备显存不足导致崩溃
  • 面试官问"GPU 显存泄漏",你不知道

学完本章,你能回答:

  • Image 对象只占几个字节,为什么内存暴增?
  • 纹理上传的完整链路是什么?
  • 如何显式释放 WebGL 资源?
  • 纹理压缩格式怎么选?

一、核心问题:Image 对象只占几个字节,为什么内存暴增?

// 创建一个Image对象
const img = new Image();
img.src = '4MB-texture.png';

// 在JS堆中,img对象本身只占用几百字节
// 但加载完成后,浏览器总内存增加了4MB+
// 多出来的内存在哪里?

答案:Image 对象只是一个引用/句柄,真正的像素数据存储在浏览器其他内存区域,最终上传到GPU 显存


二、完整的内存链路

GPU显存冷库比喻

想象GPU显存是一个大冷库:

  • 纹理(Texture) = 冰库里的货物(占用大量空间)
  • 缓冲(Buffer) = 冷库的储物盒
  • 帧缓冲(FrameBuffer) = 工作台
  • 着色器程序(Shader Program) = 说明书

冷库空间有限,放太多货物会满!

图片加载的完整流程

1. JS创建Image对象
       |
       v
2. V8堆中分配一个小对象(约200字节)
   包含:src字符串、宽度、高度、onload回调引用
       |
       v
3. 浏览器发起网络请求,下载图片文件
       |
       v
4. 图片文件下载到浏览器内存(网络缓存区域)
       |
       v
5. 浏览器解码图片(PNG/JPG -> 原始像素数据)
   解码后的像素数据存储在:图片解码缓冲区
   大小 = 宽度 x 高度 x 4字节(RGBA)
   例如:1024x1024图片 = 4MB
       |
       v
6. 图片显示在页面上(HTML img元素)
   或者通过Canvas/WebGL使用
       |
       v
7. 如果使用WebGL/Canvas 2D:
   像素数据上传到GPU显存,成为纹理
   GPU纹理可能占用更多内存(考虑mipmap、对齐等)

内存分布示意图

V8堆(JS堆):
  img = { src: "4MB-texture.png", width: 1024, height: 1024 }
  大小:约200字节

Blink堆(浏览器引擎):
  HTMLImageElement对象
  大小:约1-2KB

浏览器内存(图片解码缓冲区):
  原始像素数据:1024 x 1024 x 4 = 4MB

GPU显存:
  纹理:1024 x 1024 x 4 = 4MB
  (如果生成mipmap,可能额外占用 4MB x 1.33 = 5.3MB)

总计:约 200字节 + 1KB + 4MB + 4-5MB ≈ 9MB

关键认知:JS 堆中的 Image 对象只占 200 字节,但整个链路占用了约 9MB!

三段比喻:钥匙、压缩包、货物

  • Image对象 = 钥匙(JS堆,很小,只有几百字节)
  • 压缩图片 = 压缩包(浏览器内存,中等,文件原始大小)
  • 解码后的纹理 = 货物(GPU显存,很大,宽×高×4字节)

一把钥匙(Image对象)就能打开冷库(GPU显存)里对应货物(纹理)的柜子。但钥匙丢了不代表货物也消失了——必须显式释放!


三、Canvas 2D 的内存链路

Canvas的内存分布

// 创建一个Canvas
const canvas = document.createElement('canvas');
canvas.width = 1920;
canvas.height = 1080;

const ctx = canvas.getContext('2d');

内存分布

V8堆:
  canvas对象:约200字节
  ctx对象:约200字节

Blink堆:
  HTMLCanvasElement对象:约1KB
  CanvasRenderingContext2D对象:约1KB

浏览器内存(位图缓冲区):
  Canvas backing store(后台存储):
  1920 x 1080 x 4 = 8.3MB

GPU显存(如果浏览器使用GPU加速Canvas):
  纹理:1920 x 1080 x 4 = 8.3MB

总计:约 16-17MB

Canvas的尺寸限制

不同浏览器对 Canvas 的最大尺寸有限制:

浏览器 最大尺寸 总像素数限制
Chrome 32767 x 32767 约2GB
Firefox 32767 x 32767 约2GB
Safari 4096 x 4096(某些版本) varies

超过限制时,Canvas 会创建失败或返回空上下文。


四、WebGL 的内存链路

WebGL资源的内存分布

// 创建WebGL纹理
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1024, 1024, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);

内存分布

V8堆:
  texture = WebGLTexture对象(引用)
  大小:约几十字节

GPU显存:
  纹理数据:1024 x 1024 x 4 = 4MB
  如果启用mipmap:额外 4MB x 1/3 = 1.3MB
  纹理元数据:几百字节

JS堆中**没有**像素数据的副本!

关键认知:WebGL 的 texImage2D 会把像素数据复制到 GPU 显存,但 JS 堆中只保留一个纹理引用。

WebGL资源显式释放

WebGL 资源不会自动释放!必须手动调用删除函数:

// 删除纹理
gl.deleteTexture(texture);

// 删除缓冲区
gl.deleteBuffer(buffer);

// 删除着色器
gl.deleteShader(shader);
gl.deleteProgram(program);

// 删除帧缓冲
gl.deleteFramebuffer(fbo);

// 删除渲染缓冲
gl.deleteRenderbuffer(renderbuffer);

不释放的后果

// 内存泄漏示例
function loadTexture(gl, src) {
  const texture = gl.createTexture();
  // ... 加载图片并上传到纹理 ...
  return texture;
}

// 每关加载新纹理,但不删除旧纹理
function nextLevel() {
  const texture = loadTexture(gl, 'level' + level + '.png');
  // texture 在关卡结束后没有被删除!
  // GPU显存持续增长,最终OOM
}

正确的资源管理

class ResourceManager {
  constructor(gl) {
    this.gl = gl;
    this.textures = new Set();
    this.buffers = new Set();
  }
  
  createTexture() {
    const texture = this.gl.createTexture();
    this.textures.add(texture);
    return texture;
  }
  
  createBuffer() {
    const buffer = this.gl.createBuffer();
    this.buffers.add(buffer);
    return buffer;
  }
  
  dispose() {
    // 删除所有纹理
    this.textures.forEach(tex => this.gl.deleteTexture(tex));
    this.textures.clear();
    
    // 删除所有缓冲区
    this.buffers.forEach(buf => this.gl.deleteBuffer(buf));
    this.buffers.clear();
  }
}

// 使用
const rm = new ResourceManager(gl);

// 关卡切换时清理资源
function nextLevel() {
  rm.dispose();  // 删除上一关的所有资源
  
  const texture = rm.createTexture();
  // ... 加载新资源 ...
}

五、纹理生命周期详解

生命周期步骤

  1. 创建纹理对象
  2. 绑定纹理
  3. 设置纹理参数
  4. 上传纹理数据
  5. 使用纹理
  6. 释放纹理

完整代码示例

// 1. 创建纹理
const texture = gl.createTexture();

// 2. 绑定纹理
gl.bindTexture(gl.TEXTURE_2D, texture);

// 3. 设置纹理参数
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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);

// 4. 上传纹理数据
const image = new Image();
image.onload = () => {
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
};
image.src = 'texture.png';

// 5. 使用纹理
// 在绘制时绑定纹理
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);

// 6. ⚠️ 重要!释放纹理
gl.deleteTexture(texture); // 显式释放GPU显存

常见错误:忘记释放纹理

// ❌ 错误:纹理用完不释放
function loadScene() {
  const texture = gl.createTexture(); // 创建
  // 使用纹理
  // 离开场景,没有释放!
  // texture仍占用GPU显存!
}

// ✅ 正确:释放纹理
function unloadScene() {
  gl.deleteTexture(texture); // 显式释放
  texture = null; // 释放JS引用
}

六、显存优化策略

策略1:纹理图集(Texture Atlas)

把多个小图合并成一张大图:

┌─────────────────────────────────┐
│                                 │
│   ┌─────┐  ┌─────┐  ┌─────┐   │
│   │ img1│  │ img2│  │ img3│   │
│   └─────┘  └─────┘  └─────┘   │
│                                 │
│   ┌─────┐  ┌─────┐            │
│   │ img4│  │ img5│            │
│   └─────┘  └─────┘            │
└─────────────────────────────────┘

优点:

  • 减少纹理切换次数(减少 DrawCall)
  • 减少 GPU 内存碎片
  • 节省内存(统一 padding)

策略2:根据屏幕密度加载合适尺寸

function loadTexture(devicePixelRatio) {
  if (devicePixelRatio >= 2) {
    return loadTexture('texture@2x.png'); // 2倍图
  } else {
    return loadTexture('texture.png'); // 普通图
  }
}

策略3:不用的纹理立即释放

// 场景切换时释放旧纹理
function changeScene(oldScene, newScene) {
  // 释放旧场景的所有纹理
  oldScene.textures.forEach(texture => {
    gl.deleteTexture(texture);
  });
  
  // 加载新场景
  newScene.load();
}

策略4:使用 WebP 格式

WebP 比 PNG/JPG 更小:

  • 文件更小 → 下载更快
  • 解码后像素数据一样 → 显存占用相同

策略5:纹理压缩

WebGL 支持纹理压缩格式:

  • ETC1/ETC2(Android)
  • PVRTC(iOS)
  • ASTC(跨平台)

纹理压缩可以让显存占用减少 75%+!

格式 压缩比 适用平台 质量
RGBA8(未压缩) 1:1 全平台 无损
ETC2 4:1 Android/Web 有损
ASTC 4:1~8:1 移动端 可调
BCn (DXT) 4:1 桌面端 有损
PVRTC 4:1~8:1 iOS 有损

七、Cocos Creator 中的纹理内存管理

纹理加载的内存链路

// Cocos Creator中加载纹理
const spriteFrame = await assetManager.loadRemote('http://example.com/sprite.png');

内存分布

V8堆(JS/TS):
  spriteFrame对象:约1KB
  texture2D对象:约1KB

C++引擎层:
  SpriteFrame对象:约2KB
  Texture2D对象:约2KB
  图片解码数据:width x height x 4字节

GPU显存:
  GPU纹理:width x height x 4字节
  (如果启用mipmap,额外33%)

Cocos Creator 的资源释放

// 释放单个资源
assetManager.releaseAsset(spriteFrame);

// 释放图集中的所有资源
assetManager.releaseAsset(atlas);

// 释放Bundle中的所有资源
assetManager.getBundle('game').releaseAll();

// 强制垃圾回收(仅Debug模式)
cc.sys.garbageCollect();

重要releaseAsset 只会减少引用计数。如果其他地方还有引用,资源不会被真正释放。


八、小程序/小游戏的特殊限制

微信小游戏的内存限制

平台 JS内存限制 总内存限制
微信Android 约256-512MB 约1-2GB
微信iOS 约256MB 约1GB
字节跳动 类似微信 类似微信

超出限制的后果

  • 游戏闪退(iOS)
  • 被系统杀掉(Android)
  • 报错:Out of Memory

微信小游戏的内存优化策略

// 1. 及时释放不再使用的纹理
wx.triggerGC();  // 触发V8垃圾回收(仅在某些版本可用)

// 2. 控制同时加载的资源数量
// 不要一次性加载所有关卡资源

// 3. 使用压缩纹理
// ETC2、ASTC等压缩纹理可以减少显存占用

// 4. 降低大图的尺寸
// 对于不重要的背景图,可以使用更低分辨率

九、监控显存

估算当前纹理显存占用

// 需要自己记录所有纹理
const textureManager = {
  textures: [],
  
  add(texture, width, height) {
    this.textures.push({ texture, width, height });
    console.log(`Texture added: ${width}x${height}, ~${(width * height * 4 / 1024 / 1024).toFixed(2)}MB`);
  },
  
  remove(texture) {
    const index = this.textures.findIndex(t => t.texture === texture);
    if (index >= 0) {
      this.textures.splice(index, 1);
    }
  },
  
  getTotalMemory() {
    return this.textures.reduce((sum, t) => {
      return sum + t.width * t.height * 4; // RGBA 8位
    }, 0);
  }
};

十、常见问题与解决方案

问题1:Canvas离屏渲染忘记释放

// ❌ 错误:离屏Canvas用完不释放
function renderOffscreen() {
  const offscreenCanvas = document.createElement('canvas');
  offscreenCanvas.width = 1024;
  offscreenCanvas.height = 1024;
  // 渲染
  // 没有释放,DOM引用还在!
}

// ✅ 正确:释放Canvas
function renderOffscreen() {
  const offscreenCanvas = document.createElement('canvas');
  // 使用
  offscreenCanvas = null; // 释放引用,帮助回收
}

问题2:WebGLContext丢失处理

浏览器可能会回收WebGLContext(如页面在后台太久、移动端内存压力),这是移动端游戏必遇的问题:

canvas.addEventListener('webglcontextlost', (event) => {
  event.preventDefault();
  console.log('WebGL context lost');
  // 暂停游戏渲染,停止所有WebGL调用
});

canvas.addEventListener('webglcontextrestored', () => {
  console.log('WebGL context restored');
  // 重新创建所有GPU资源
  reinitWebGL();
});

function reinitWebGL() {
  // 1. 重新创建着色器程序
  // 2. 重新创建纹理并重新上传
  // 3. 重新创建缓冲区
  // 4. 重新设置渲染状态
  // 建议:维护一个资源清单,Context丢失后按清单重建
}

最佳实践:在TextureManager中维护所有纹理的URL引用,Context丢失后可以按清单重建:

class TextureManager {
  constructor(gl) {
    this.gl = gl;
    this.textureCache = new Map();
    this.textureMeta = new Map();
  }

  async loadTexture(url) {
    if (this.textureCache.has(url)) {
      return this.textureCache.get(url);
    }

    const texture = this.gl.createTexture();
    // 加载逻辑...

    this.textureCache.set(url, texture);
    this.textureMeta.set(url, { url, loaded: true });
    return texture;
  }

  releaseTexture(url) {
    const texture = this.textureCache.get(url);
    if (texture) {
      this.gl.deleteTexture(texture);
      this.textureCache.delete(url);
      this.textureMeta.delete(url);
    }
  }

  releaseAll() {
    this.textureCache.forEach(texture => {
      this.gl.deleteTexture(texture);
    });
    this.textureCache.clear();
    this.textureMeta.clear();
  }

  async restoreAll() {
    const urls = [...this.textureMeta.keys()];
    this.textureCache.clear();
    for (const url of urls) {
      await this.loadTexture(url);
    }
  }
}

const textureManager = new TextureManager(gl);

canvas.addEventListener('webglcontextrestored', () => {
  textureManager.restoreAll();
});

自问自答

Q:为什么 V8 的 GC 回收了 Image 对象,但内存没有下降? A:因为 V8 GC 只回收 JS 堆中的对象。Image 对象对应的像素数据存储在浏览器图片解码缓冲区和 GPU 显存中,不在 V8 堆中。只有当浏览器确定图片不再被使用时(没有任何引用),才会释放这些内存。

Q:如何检测 WebGL 资源泄漏? A:可以通过以下方法:

  1. Chrome DevTools -> Performance -> Memory,观察 GPU 内存曲线
  2. Chrome Task Manager(Shift+Esc),查看 GPU Memory 列
  3. 在 WebGL 上下文中调用 gl.getParameter(gl.TEXTURE_BINDING_2D) 等查询当前绑定的资源
  4. 使用 Spector.js 捕获每一帧的 WebGL 调用,分析资源创建和销毁

Q:Cocos Creator 中为什么有时候 releaseAsset 后内存不降? A:可能的原因:

  1. 其他地方还有对该资源的引用
  2. GPU 显存的释放有延迟(驱动层缓冲)
  3. 浏览器的内存分配器没有立即把内存还给操作系统
  4. 图片解码缓冲区还在

Q:为什么 Canvas.width = 0 不能释放内存? A:设置 Canvas.width = 0 确实会释放 backing store(位图缓冲区),但 Canvas 对象本身还在。要完全释放,需要:

canvas.width = 0;
canvas.height = 0;
// 并且确保没有任何引用指向这个canvas
canvas = null;  // 让GC回收Canvas对象

实践任务

  • 任务1:追踪一张图片从 new Image()gl.deleteTexture() 的完整内存变化
  • 任务2:实现一个显存统计工具,自动计算所有已加载纹理的总显存占用
  • 任务3:对比未压缩纹理和 ETC2/ASTC 压缩纹理的显存占用
  • 任务4:用 gl.deleteTexture() 释放纹理后,用 Chrome Task Manager 验证 GPU 内存下降
  • 任务5:实现一个 TextureManager 类,支持 LRU 淘汰和显存上限控制

与其他章节的关联

本章内容 关联章节 关联点
纹理上传链路 第05章 全链路内存的 GPU 显存部分
WebGL资源释放 第07章 内存泄漏排查的重要目标
纹理压缩 第09章 H5游戏内存优化的核心手段
Cocos纹理管理 2_2_h5-rendering-mastery 引擎层面的资源管理

上一章:05-浏览器全链路内存全景图 下一章:07-内存泄漏排查SOP