7.2 纹理、显存与资源预算
7.1 已经说清一件事:资源内存在引擎与 GPU 侧,不归 JS 垃圾回收管。卷二 13.2 也给过「宽 × 高 × 每像素字节」的粗算和一张格式对照表。但那份粗算有两个缺口:它没落到引擎真实的格式枚举上,也没把块压缩的对齐算进去——而恰恰是这两点,让「估算值」和「Profiler 实测值」经常对不上。
本节把这笔账做细。先分清资源侧与 GPU 侧两套格式枚举,再把 mipmap、块对齐、2 的幂对齐三笔容易漏的账写进一个纯 TypeScript 函数,用 vitest 钉住它的行为;然后给出《方块冲刺》的整包资源预算表,以及运行时读取真实显存的两个 API。所有显存数字都是量级参考,需在你的目标设备上用 Profiler 复测。
7.2.1 两套格式枚举:资源侧与 GPU 侧
Cocos 里有两条独立的格式线,混用是预算表出错的常见原因:
- 资源侧
PixelFormat:导入图片时编辑器把文件压成这个格式,它决定了文件体积与内存占用。在cc.d.ts里以内部名_cocos_asset_assets_asset_enum__PixelFormat暴露。 - GPU 侧
gfx.Format:运行时把资源上传 GPU 时映射成的硬件格式。
两边都有一堆数字,含义却不同。下面是 cc.d.ts 的节选证据:
// 来源:@cocos/creator-types@3.8.8, engine/cc.d.ts
// 资源侧 PixelFormat(节选)
RGB565 = 47,
RGB5A1 = 49,
RGBA4444 = 50,
RGB888 = 24,
// GPU 侧 gfx.Format(节选)
RGB8 = 24,
RGBA8 = 35,
ETC2_RGB8 = 73,
ASTC_RGBA_4X4 = 89,
看第一眼会以为 RGB888 = 24 和 RGB8 = 24 是同一个东西——它们只是恰好都取值 24,语义分属两条线。写预算表时只用资源侧的格式名(RGBA8888、ETC2_RGB、ASTC_6x6),因为决定占用的是它。不要拿 gfx.Format 的枚举值去填 PixelFormat 的字段,那种错误编译器不一定拦得住。
7.2.2 每像素比特与块压缩
未压缩格式按「每像素比特」线性算;块压缩格式按「每 4×4 或 6×6 像素一个定长数据块」算。对照表:
| 格式 | 每像素比特 | 块边长 | 备注 |
|---|---|---|---|
| RGBA8888 | 32 | 1 | 无损,内存最大 |
| RGB888 | 24 | 1 | 无 alpha 通道 |
| RGB565 | 16 | 1 | 有损,渐变易出色带 |
| RGBA4444 | 16 | 1 | alpha 精度低 |
| ETC2_RGB | 4 | 4 | Android 基线格式 |
| ETC2_RGBA | 8 | 4 | 带 alpha |
| ASTC_4x4 | 8 | 4 | 质量与体积折中 |
| ASTC_6x6 | 3.56 | 6 | iOS 常用 |
| PVRTC_4bpp | 4 | 4 | iOS 老机型 |
关键认知在这里:块压缩的「每像素比特」是一个平均值。ASTC_6x6 每个块固定 128 bit,摊到 36 个像素上就是 128 / 36 ≈ 3.56。所以块压缩的显存不是「像素数 × 3.56」,而是「块数 × 128 bit」,并且块数要按 6×6 向上取整。一张 1023×1023 的图在 ASTC_6x6 下占的块数,和 1026×1026 完全一样——这是粗算公式算不出来的差异。
7.2.3 显存估算:三笔容易漏的账
从「像素数」到「显存字节数」,中间隔着三笔账:
- mipmap:每级尺寸减半,总和约 1.33×(几何级数 1 + 1/4 + 1/16 + … = 4/3)。2D 游戏里精灵缩放不剧烈时可以关掉。
- 块对齐:块压缩格式的尺寸要向上取整到块边长的整数倍,多出来的像素照样占块。
- 2 的幂对齐:老 GPU 对非 2 的幂纹理支持差,勾选后尺寸向上取到 512 / 1024 / 2048,一张 900×900 的图会变成 1024×1024。
第 2 笔和第 3 笔都会让「估算值」偏小。工程上的做法不是把公式写得无比精确,而是让公式略微高估——高估会被 Profiler 实测值纠正,低估则会让你在低端机上翻车。
7.2.4 L1:纯 TS 显存计算函数
下面这个函数把上面三笔账都算进去。它是纯 TypeScript,零 cc 依赖,可以直接单测:
// mini2d/texture/vram.ts —— 纯 TS,无任何 cc 依赖
export interface FormatInfo {
name: string;
bitsPerPixel: number;
/** 块压缩的块边长(像素);非压缩格式为 1 */
blockSize: number;
}
export const FORMATS: Record<string, FormatInfo> = {
rgba8888: { name: 'RGBA8888', bitsPerPixel: 32, blockSize: 1 },
rgb888: { name: 'RGB888', bitsPerPixel: 24, blockSize: 1 },
rgb565: { name: 'RGB565', bitsPerPixel: 16, blockSize: 1 },
rgba4444: { name: 'RGBA4444', bitsPerPixel: 16, blockSize: 1 },
etc2_rgb: { name: 'ETC2_RGB', bitsPerPixel: 4, blockSize: 4 },
etc2_rgba: { name: 'ETC2_RGBA', bitsPerPixel: 8, blockSize: 4 },
astc_4x4: { name: 'ASTC_4x4', bitsPerPixel: 8, blockSize: 4 },
astc_6x6: { name: 'ASTC_6x6', bitsPerPixel: 3.56, blockSize: 6 },
pvrtc_4bpp: { name: 'PVRTC_4bpp', bitsPerPixel: 4, blockSize: 4 },
};
export interface VramOptions {
mipmaps?: boolean;
powerOfTwo?: boolean;
}
function nextPow2(n: number): number {
let p = 1;
while (p < n) {
p *= 2;
}
return p;
}
/** 返回该纹理占用的显存字节数(估算,偏保守) */
export function estimateVram(
width: number,
height: number,
format: string,
options: VramOptions = {},
): number {
const info = FORMATS[format];
if (!info) {
throw new Error(`unknown format: ${format}`);
}
let w = width;
let h = height;
if (options.powerOfTwo) {
w = nextPow2(w);
h = nextPow2(h);
}
const blocksX = Math.ceil(w / info.blockSize);
const blocksY = Math.ceil(h / info.blockSize);
const bitsPerBlock = info.bitsPerPixel * info.blockSize * info.blockSize;
let bytes = Math.ceil((blocksX * blocksY * bitsPerBlock) / 8);
if (options.mipmaps) {
bytes = Math.round((bytes * 4) / 3);
}
return bytes;
}
export function formatVram(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
vitest 断言把关键性质钉住:
import { describe, expect, it } from 'vitest';
import { estimateVram, formatVram } from './vram';
describe('estimateVram', () => {
it('RGBA8888 1024² 恰好 4 MB', () => {
expect(estimateVram(1024, 1024, 'rgba8888')).toBe(4 * 1024 * 1024);
});
it('ETC2_RGB 约为 RGBA8888 的 1/8', () => {
const raw = estimateVram(1024, 1024, 'rgba8888');
const etc2 = estimateVram(1024, 1024, 'etc2_rgb');
expect(etc2).toBe(512 * 1024);
expect(raw / etc2).toBe(8);
});
it('块压缩按块向上取整:1025 与 1028 同价', () => {
const a = estimateVram(1025, 1025, 'etc2_rgb');
const b = estimateVram(1028, 1028, 'etc2_rgb');
expect(a).toBe(b);
expect(a).toBeGreaterThan(estimateVram(1024, 1024, 'etc2_rgb'));
});
it('mipmap 约增加 1/3', () => {
const base = estimateVram(512, 512, 'rgba8888');
const mip = estimateVram(512, 512, 'rgba8888', { mipmaps: true });
expect(mip).toBe(Math.round((base * 4) / 3));
});
it('ASTC_6x6 1024² 约 0.45 MB', () => {
expect(formatVram(estimateVram(1024, 1024, 'astc_6x6'))).toBe('0.45 MB');
});
});
这组数字和卷二 13.2 那张表能对上:1024² 的 RGBA8888 是 4 MB,ETC2 约 0.5 MB,ASTC_6x6 约 0.45 MB。能对上,说明两份估算用的是同一个口径;如果这里算出 ETC2 是 1 MB,那多半是把 4 bit 当成 4 byte 了。
7.2.5 《方块冲刺》整包资源预算表
有了函数,就能给整个项目排预算。下表是《方块冲刺》的参考口径(960×540 设计分辨率,H5 / 微信 / 原生三端):
| 资源类别 | 规模 | 格式 | 估算显存 | 备注 |
|---|---|---|---|---|
| 主 UI 图集 | 2048² | ASTC_6x6 | ~1.8 MB | 常驻 |
| 关卡图集(单关) | 1024² × 2 | ETC2_RGB | ~1.0 MB | 随关卡进出 |
| 角色/敌人序列帧 | 2048² | ASTC_4x4 | ~4.0 MB | 常驻,质量优先 |
| 背景(分层) | 2048×1024 × 3 | ETC2_RGB | ~1.5 MB | 常驻 |
| 字体图集 | 1024² | RGBA8888 | ~4.0 MB | 不压缩,需清晰 |
| RenderTexture | 960×540 × 2 | RGBA8888 | ~4.0 MB | 4.1 会用到 |
| 合计(峰值) | ~16 MB | 不含音频 |
这张表有两处值得展开。第一,字体图集保留未压缩:文字边缘一旦被块压缩,笔画会糊,用户一眼能看出;同理,UI 里的小图标也宁可走轻压缩。第二,峰值出现在「切关瞬间」:上一关的图集还没释放,下一关的已经加载。预算表要按峰值排,不能按稳态排——这也是 7.1 里「谁 load 谁 decRef、切场景前释放」那条纪律存在的意义。
这张表是参考值,需在你的设备上复测。 低端安卓机的可用显存上限可能只有 100 MB 上下,iOS 稍宽;把峰值压到 16 MB 是为了给系统和别的 App 留余地,而不是因为 16 MB 是硬上限。
7.2.6 读到真实数字:Profiler 与 gfx
估算终归是估算,运行时得读真实值。两个来源:
profiler.stats.textureMemory/stats.bufferMemory:IProfilerState的两个计数项,值在.counter.value上,单位是 MB。这是最常用的。director.root?.device.memoryStatus.textureSize:gfx设备侧统计的纹理字节数。gfx.MemoryStatus有textureSize与bufferSize两个字段,单位是字节。
import { _decorator, Component, profiler, director } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('VramWatch')
export class VramWatch extends Component {
@property
budgetMb = 64;
update(): void {
const stats = profiler.stats;
if (!stats) return;
const texMb = stats.textureMemory.counter.value;
if (texMb > this.budgetMb) {
console.warn(`[vram] texture ${texMb.toFixed(1)}MB > budget ${this.budgetMb}MB`);
}
}
/** 设备侧纹理字节数;原生端与 Web 端都可用 */
deviceTextureBytes(): number {
const device = director.root?.device;
return device ? device.memoryStatus.textureSize : 0;
}
}
两个来源的差别要留意:profiler.stats.textureMemory 是引擎按资源登记的估算值,gfx 的 textureSize 是设备侧实际分配值,通常后者略大(对齐、内部格式填充)。两者对不上不必惊慌,看趋势即可——真正要抓的是「切关后不回落」这种泄漏型曲线。
7.2.7 L3:纹理导入设置写在哪
纹理的压缩格式不是在代码里设的,而是在编辑器里对每张图设,结果写进同目录的 .meta。下面是与压缩相关的结构节选。本段是 Cocos Creator 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。
{
"ver": "1.0.25",
"importer": "texture",
"uuid": "a1b2c3d4-0000-4000-8000-000000000001",
"userData": {
"type": "sprite-frame",
"fixAlphaTransparencyArtifacts": true,
"compressSettings": {
"android": { "format": "etc2", "quality": 3 },
"ios": { "format": "astc_6x6", "quality": 3 },
"web": { "format": "rgba8888" }
}
}
}
在编辑器里打开会看到什么:在「资源管理器」里选中这张图,右侧「属性检查器」出现一组「压缩」下拉,分别对应 Android、iOS、Web 三端;format 就是上表里那些格式名,quality 是一个 0~3 的滑杆。改完点「应用」,值会写回这份 .meta。
(以上工程文件需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。)
注意 fixAlphaTransparencyArtifacts 这一项:它专门修「半透明边缘出现黑边」的问题,代价是导入时要做一次像素处理。UI 图与带软边的精灵建议打开,纯色块图可以关掉省时间。
7.2.8 工程决策:预算超了先砍谁
预算超了,砍的顺序不是「谁大砍谁」,而是按「收益 / 代价」排:
| 手段 | 收益 | 代价 | 适用 |
|---|---|---|---|
| 换压缩格式 | 体积降到 1/8 | 有损、平台不通用 | 背景、装饰等大面积图 |
| 降分辨率 | 体积降到 1/4 | 变糊,近景可辨 | 大尺寸但显示面积小的图 |
| 关 mipmap | 省 25% | 缩小采样有噪点 | 2D、缩放不剧烈的场景 |
| 拆包按需加载 | 降峰值 | 加载卡顿风险 | 关卡图集 |
| 用 9 宫格/程序化替代 | 直接省掉 | 表现力受限 | 可平铺的背景与边框 |
一句话原则:先动「大面积、可容忍细节损失」的图,最后才动 UI 与文字。反过来做,省下的字节不多,用户体验先崩了。
下一节把「算出来的预算」变成「测得准的闭环」:用 Profiler 采样、定位超标的那一段、修复、再用同一设备复测验证。预算表给出目标,闭环负责确认你真的达到了目标。
阅读导航:上一节:7.1 内存模型与 GC · 下一节:7.3 性能剖析与优化闭环 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。