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 × 512 | 1 MB | 1.33 MB | ~0.13 MB |
| 1024 × 1024 | 4 MB | 5.33 MB | ~0.5 MB |
| 2048 × 2048 | 16 MB | 21.3 MB | ~2 MB |
| 4096 × 4096 | 64 MB | 85.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 甚至更低。代价是「有损」且「平台不通用」——这是本节最需要记住的一句话。
| 平台 | 推荐格式 | 每像素比特 | 备注 |
|---|---|---|---|
| iOS | ASTC 6×6 | 3.56 | A8 及以上机型支持 |
| Android | ETC2 (RGB) | 4 | OpenGL ES 3.0 基线格式 |
| Android 高端 | ASTC 6×6 | 3.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 验证压缩是否真的生效
配完压缩预设,别急着发版——压缩预设写对了不等于产物里真的压了。三处交叉验证:
- 看构建日志:构建时控制台会逐张打印「纹理名 + 目标格式 + 尺寸」,出现
compressing ... to astc_6x6这类行才算走到压缩;若某张图被跳过,通常会给出原因(尺寸非 2 的幂、格式不支持等)。 - 看产物目录:
build/web-mobile/assets/下,压缩后的纹理文件名里会带上格式后缀(如.astc/.etc2),体积比原 PNG 小得多。 - 看运行时 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 与帧预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。