《Cocos 游戏开发高级编程》7.2 纹理、显存与资源预算

7.1 说明资源内存在引擎与 GPU 侧、不归 GC 管。本节把这笔账算细:落到 cc.d.ts 里的 PixelFormat 与 gfx.Format 两套枚举,把块压缩对齐与 mipmap 的 1.33 倍写进公式,用纯 TS 写一个可单测的显存计算函数,并给出整包资源预算表与读取真实显存的 API。

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 像素一个定长数据块」算。对照表:

格式每像素比特块边长备注
RGBA8888321无损,内存最大
RGB888241无 alpha 通道
RGB565161有损,渐变易出色带
RGBA4444161alpha 精度低
ETC2_RGB44Android 基线格式
ETC2_RGBA84带 alpha
ASTC_4x484质量与体积折中
ASTC_6x63.566iOS 常用
PVRTC_4bpp44iOS 老机型

关键认知在这里:块压缩的「每像素比特」是一个平均值。ASTC_6x6 每个块固定 128 bit,摊到 36 个像素上就是 128 / 36 ≈ 3.56。所以块压缩的显存不是「像素数 × 3.56」,而是「块数 × 128 bit」,并且块数要按 6×6 向上取整。一张 1023×1023 的图在 ASTC_6x6 下占的块数,和 1026×1026 完全一样——这是粗算公式算不出来的差异。

7.2.3 显存估算:三笔容易漏的账

从「像素数」到「显存字节数」,中间隔着三笔账:

  1. mipmap:每级尺寸减半,总和约 1.33×(几何级数 1 + 1/4 + 1/16 + … = 4/3)。2D 游戏里精灵缩放不剧烈时可以关掉。
  2. 块对齐:块压缩格式的尺寸要向上取整到块边长的整数倍,多出来的像素照样占块。
  3. 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² × 2ETC2_RGB~1.0 MB随关卡进出
角色/敌人序列帧2048²ASTC_4x4~4.0 MB常驻,质量优先
背景(分层)2048×1024 × 3ETC2_RGB~1.5 MB常驻
字体图集1024²RGBA8888~4.0 MB不压缩,需清晰
RenderTexture960×540 × 2RGBA8888~4.0 MB4.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 性能剖析与优化闭环 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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