《Cocos 游戏开发实战》13.2 纹理、内存与 GC

合批解决的是每帧提交次数,纹理内存决定的却是小游戏能不能启动。本节先算清一张纹理占多少内存,再对照 iOS、Android、H5 与微信小游戏各自的压缩格式,讲透 addRef/decRef 与 release 的引用计数规则,给出关卡图集加载与释放的可编译脚本,最后用对象池压住每帧 GC 抖动。

13.2 纹理、内存与 GC

上一节把 Draw Call 从 180 压到了 42,但那只解决了「每帧提交多少次」。真正决定《方块冲刺》能不能在微信小游戏里启动的,是另一个数字:纹理内存。微信小游戏对包体有硬上限,对运行时内存也敏感,一张没压缩的 2048 大图就吃掉十几 MB,几张下来低端安卓机就开始闪退。

这一节按「先算账、再选格式、最后管生命周期」的顺序走:先算清一张纹理到底占多少内存,再对照各平台的压缩格式,然后讲清 addRef / decRef / release 这套引用计数规则,最后用对象池把每帧的 GC 抖动压下去。

需要提前说清楚:Cocos 的纹理内存不在 JS 堆里,performance.memory 之类的浏览器 API 看不到它,得靠引擎自己的 Profiler。这也是为什么这一节要单独立出来——它和普通的 JS 内存优化完全不是一回事。

13.2.1 一张纹理到底占多少内存

内存占用由三个量相乘:宽 × 高 × 每像素字节数。未压缩的 RGBA8888 是每像素 4 字节,于是:

尺寸RGBA8888含 mipmap(×1.33)ETC2 / ASTC 6×6
512 × 5121 MB1.33 MB~0.13 MB
1024 × 10244 MB5.33 MB~0.5 MB
2048 × 204816 MB21.3 MB~2 MB
4096 × 409664 MB85.3 MB~8 MB

算一次就明白为什么「图集越大越好」是错的。13.1 里我们把小图标打进一张 2048 × 2048 自动图集,若这张图不压缩,单它一张就是 16 MB——比整个首包的脚本还大。这也是为什么 13.1 强调「合批要合并的是同屏、同频切换的图」,而不是把全站资源都塞进去。

mipmap 是另一笔账:开 mipmap 会让纹理内存乘约 1.33 倍,换来的是远处物体缩小后的采样质量。2D 游戏里精灵缩放不剧烈时,关掉 mipmap 是常见选择;3D 或大量缩放的场景才需要开。

自动图集还有两笔隐含开销。一是 padding:13.1 里设的 Padding = 2,意味着每张小图四周各留 2 像素空白,防止相邻小图在采样时互相「渗色」;图集里的小图越多,这笔空白累积得越多,所以别把几百张碎图硬塞进一张图集。二是 powerOfTwo:勾选后图集尺寸会向上取到 2 的幂(512 / 1024 / 2048),某些老 GPU 对非 2 的幂纹理支持不佳,但对现代设备而言这是历史包袱,可以按目标机型决定是否保留。

内存账要和 13.1 的 Draw Call 账一起看。同样的 60 个金币图标:散图会让 Draw Call 涨到 60,但每张只占很小的独立纹理;打成一张 2048 图集则把 Draw Call 压到个位数,代价是一张 16 MB(未压缩)的大纹理。优化从来是权衡,不存在「既要又要」的免费午餐。

13.2.2 纹理压缩:选错格式等于白做

纹理压缩的价值在于:GPU 能直接采样压缩数据,不用先解压成 RGBA,内存占用能降到 1/8 甚至更低。代价是「有损」且「平台不通用」——这是本节最需要记住的一句话。

平台推荐格式每像素比特备注
iOSASTC 6×63.56A8 及以上机型支持
AndroidETC2 (RGB)4OpenGL ES 3.0 基线格式
Android 高端ASTC 6×63.56需运行时探测再降级
H5 / 桌面浏览器不压缩(或按浏览器支持 ASTC)32走浏览器解码,视支持度而定
微信小游戏ETC2 或 ASTC(按机型分组)4 / 3.56在构建面板勾选,见 13.2.6

同一张 1024 × 1024 的图,RGBA8888 是 4 MB,ETC2 约 0.5 MB,ASTC 6×6 约 0.45 MB——差了将近一个数量级。代价是 Android 上 ETC2 与 iOS 上 ASTC 不能互换,所以构建时通常要针对不同平台出多套压缩图,首包体积会因此变大。这里的每像素比特是格式规格的参考值,实际占用还需在你的目标机型上用 Profiler 复测。

一个务实的折中:把「必须清晰」的 UI 图与文字图集保留不压缩或轻压缩,把「大面积、可容忍细节损失」的背景与装饰图压成 ASTC / ETC2。别一刀切。

压缩还有一条容易被忽略的约束:压缩图不能在运行时动态修改像素。如果你需要程序化生成或逐像素写入纹理(例如画一道渐变的进度条底图),就必须用未压缩的 Texture2D,或者改用 Graphics 组件去画。把压缩预设套到这类纹理上,编辑器导入时通常不报错,运行时才发现写不进去。

最后提醒一句平台检测的写法:判断当前是不是原生端用 sys.isNative,判断是否浏览器用 sys.isBrowser(见 cc.d.ts 的 sys 对象)。不要用 UA 字符串去猜平台,那正是 14 章三端构建里最容易出错的地方。

13.2.3 引用计数:谁负责释放

Cocos 的资源是引用计数管理的。resources.load 或 bundle.load 加载一个资源后,它的 refCount 至少为 1;调用 decRef() 减一,减到 0 且引擎判定无其他引用时,资源会被自动释放。三个 API 的关系:

  • asset.addRef():加引用,阻止资源被自动释放,用于「我要长期持有」;
  • asset.decRef(autoRelease = true):减引用,减到 0 时可自动释放;
  • assetManager.releaseAsset(asset):直接把资源从缓存里移除,慎用——若别处还在用,会变成「悬空引用」,后续取到的是已销毁对象。

工程里的规则很简单:谁 load,谁 decRef。加载与释放成对出现,不要依赖 GC 兜底,因为资源内存不在 JS 堆里,GC 看不到它。

释放时机要分两类资源来定:

资源类型生命周期释放时机
关卡图集(bundle-levels)随关卡关卡结束、切换场景前
主 UI 图标常驻不主动释放,随进程结束
临时特效图一次性播放结束立即 decRef
音频 AudioClip常驻由音频管理器统一持有

常驻资源不要在每次场景切换时释放,否则切回来要重新加载,反而制造加载卡顿;临时资源必须及时释放,否则关卡切得越多、内存涨得越快,最终触发小游戏的内存回收甚至崩溃。判断某个资源该不该常驻,问自己一句:它在游戏 90% 的时间里都用得到吗?是就常驻,否则按需加载、用完就放。

还有一个坑:assetManager.releaseAsset() 是强制从缓存移除,会忽略引用计数。只在确定没有别处引用时才用,通常只出现在「切换大版本、清空所有缓存」这种场景。

13.2.4 L2:加载关卡图集并在销毁时释放

下面这段组件负责加载 bundle-levels 里的关卡图标,在 onDestroy 时统一减引用。它只依赖 cc 的公开 API,能被类型检查。

import { _decorator, Component, SpriteFrame, resources } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('LevelAssetLoader')
export class LevelAssetLoader extends Component {
  @property(SpriteFrame)
  fallback: SpriteFrame | null = null;

  private loaded: SpriteFrame[] = [];

  loadIcons(paths: string[]): void {
    resources.load(paths, SpriteFrame, (err, frames) => {
      if (err) {
        console.error('[assets] load failed', err);
        return;
      }
      // load 之后引用计数已 +1,记下来,销毁时成对减回去
      this.loaded = frames;
    });
  }

  onDestroy(): void {
    for (const frame of this.loaded) {
      frame.decRef();
    }
    this.loaded.length = 0;
  }
}

resources.load 支持传字符串数组批量加载,回调第二个参数是 SpriteFrame[]——这一点在 cc.d.ts 的 AssetManager.Bundle.load 重载里写得很清楚,写成单个 SpriteFrame 会类型不匹配。若某个资源只想临时用一次,也可以直接 resources.release('path/to/frame') 按路径释放,但前提是记得路径,所以我们更推荐按对象 decRef。

注意 resources 只是内置的 resources Bundle;《方块冲刺》的关卡资源在 bundle-levels 里,加载时要先拿到那个 Bundle:

import { assetManager, AssetManager, SpriteFrame } from 'cc';

const levels: AssetManager.Bundle | null = assetManager.getBundle('bundle-levels');
levels?.load('icons/coin', SpriteFrame, (err, frame) => {
  if (err) { console.error(err); return; }
  // 用 frame 挂到 Sprite 上……
});

assetManager.getBundle(name) 返回 AssetManager.Bundle | null——如果这个分包还没加载,它返回 null,需要先 assetManager.loadBundle('bundle-levels', cb) 把包加载进来。这一步在 15.1 讲分包策略时会展开,这里先记住:取 Bundle 一定要判空,跨包加载失败最常见的报错就是「对 null 调 load」。

13.2.5 GC:把每帧的临时分配压到零

资源内存靠引用计数,JS 对象内存靠垃圾回收。GC 的问题是它什么时候跑、跑多久都不受你控制,一次全量回收在低端机上可能就是十几毫秒的卡顿。跑酷游戏对卡顿零容忍,所以要主动把每帧的临时分配降到最低。

常见的高频分配有三类:

  • 每帧 new Vec3() / new Vec2() 做位置计算;
  • 每帧创建临时数组或对象做遍历;
  • 频繁 instantiate / destroy 节点。

前两类用「复用同一个临时对象」解决:在模块顶层放一个 const tmp = new Vec3(),所有计算都写进它,不要每帧新建。第三类用对象池。Cocos 内置了 NodePool,下面是金币对象的池化实现:

import { _decorator, Component, Node, Prefab, NodePool, instantiate } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('CoinPool')
export class CoinPool extends Component {
  @property(Prefab)
  coinPrefab: Prefab | null = null;

  private pool = new NodePool();

  spawn(): Node | null {
    const node = this.pool.size() > 0 ? this.pool.get() : this.makeNew();
    if (node) node.parent = this.node;
    return node;
  }

  recycle(node: Node): void {
    this.pool.put(node);
  }

  private makeNew(): Node | null {
    if (!this.coinPrefab) return null;
    return instantiate(this.coinPrefab);
  }

  onDestroy(): void {
    this.pool.clear();
  }
}

NodePool.get() 返回 Node | null,池空时需要自己 instantiate 兜底;put 会先调用节点上的 unuse 回调(若组件实现了),把它重置回初始状态,再挂回池里。回收时节点只是被移出场景树,并没有被销毁,所以纹理与组件都还在——这正是省下 GC 的原因。

怎么验证「每帧零分配」?在 Profiler 的 logic 毫秒曲线上看锯齿:如果每秒出现一次明显的尖峰,通常就是 GC 在跑。更彻底的办法是在 Chrome DevTools 的 Performance 面板录一段,看内存曲线是否持续爬升、是否有周期性的下降——持续爬升说明有泄漏,周期性大幅下降说明分配过猛。桌面浏览器上这两类问题都不明显,一定要在真机上复测。

顺带一提,节点本身也有成本。一个空 Node 带着 UITransform 等内置组件,几十个还无所谓,上千个就会让场景树遍历变慢。所以对象池不是「无限复用」,而是「复用高频创建销毁的那一类」,比如金币、子弹、飘字,静态的障碍物直接摆场景里就行。

13.2.6 L3:纹理压缩预设

压缩格式在构建面板的「压缩纹理」里配置,编辑器会把它写进工程设置。下面是 settings/v2/packages/builder.json 里压缩预设片段的节选。本段是 Cocos Creator 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。

{
  "__version__": "1.0.0",
  "textureCompressConfig": {
    "genMipmaps": false,
    "presets": [
      {
        "name": "default",
        "platforms": {
          "ios": { "format": "astc_6x6", "quality": "normal" },
          "android": { "format": "etc2_rgb", "quality": "normal" },
          "web-mobile": { "format": "etc2_rgb", "quality": "normal" }
        }
      }
    ]
  }
}

在编辑器里打开「构建发布」面板、切到「压缩纹理」页,你会看到什么:左侧是预设列表,只有一个名为 default 的预设;右侧是「平台 × 格式」的矩阵,iOS 一列显示 astc_6x6,Android 与 Web Mobile 两列显示 etc2_rgb,Quality 都是 Normal;底部有 Gen Mipmaps 复选框,当前未勾选。改了这里的值,保存后会写回上面那份 builder.json。

(以上工程文件需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。)

需要说明的是,一个工程可以建多个预设,再把不同目录的纹理指到不同预设上——例如给 assets/textures/bg/ 单独建一个「只压背景、开 mipmap」的预设,给 UI 目录建一个「不压、关 mipmap」的预设。预设的分配规则写在纹理资源的 .meta 里(userData.compressSettings.presetId),改的是哪张图、指向哪个预设,编辑器面板上都能看到。

13.2.7 验证压缩是否真的生效

配完压缩预设,别急着发版——压缩预设写对了不等于产物里真的压了。三处交叉验证:

  1. 看构建日志:构建时控制台会逐张打印「纹理名 + 目标格式 + 尺寸」,出现 compressing ... to astc_6x6 这类行才算走到压缩;若某张图被跳过,通常会给出原因(尺寸非 2 的幂、格式不支持等)。
  2. 看产物目录:build/web-mobile/assets/ 下,压缩后的纹理文件名里会带上格式后缀(如 .astc / .etc2),体积比原 PNG 小得多。
  3. 看运行时 Profiler:真机跑起来后读 profiler.stats.textureMemory.counter.value,压缩前后的差值应该和 13.2.1 那张表对得上。

如果三者对不上,最常见的原因是那张图被漏在了预设之外——自动图集、动态合图、以及运行时 loadRemote 拉进来的图,都可能不吃全局压缩预设,需要单独指定。这也是为什么我们宁可在构建日志里逐张确认,而不是只看一眼面板就放心。

一句话总结三处验证:日志说「压了」,产物说「压小了」,Profiler 说「省了内存」,三者齐了才算数。

13.2.8 收口

本节给工程加了三处可配置项与两条规则:

  • settings/v2/packages/builder.json 里的压缩预设,iOS 走 ASTC、Android 与 H5 走 ETC2;
  • assets/scripts/core/LevelAssetLoader.ts 与 assets/scripts/game/CoinPool.ts 两个组件;
  • 规则一:加载与释放成对,谁 load 谁 decRef;
  • 规则二:每帧不做临时分配,节点靠池复用。

验收线是:主场景纹理内存 ≤ 24 MB(压缩后,用 Profiler 的 textureMemory 读),金币生成与回收不触发明显的 GC 停顿。这条线要写进 docs/perf-baseline.md,和 13.1 的 Draw Call 基线并列,后续每次改动都对着它复核。

下一节把这两节的数字统一收进一个「帧预算」框架,讲怎么用 Profiler 定位到具体是哪一段代码吃掉了 16.7 ms。

阅读导航:上一节:13.1 Draw Call 与合批 · 下一节:13.3 Profiler 与帧预算 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画