Unity 图片资源格式与内存:ASTC、ETC2、BC 的压缩原理、平台支持与工程规范

Unity 开发高级/资深 性能优化 内存优化 资源管理
Unity 图片资源格式与内存:ASTC、ETC2、BC 的压缩原理、平台支持与工程规范

客户端项目的内存和包体问题,最后往往都会落回到同一类资源上:贴图。很多团队对它的认知停留在“导入时选个 ASTC 就行”,但真正的坑在于——压缩格式决定了显存占用、内存带宽、包体体积、设备兼容四条线,而选错格式的代价不是画质差一点,是低端机直接 OOM

这篇文章把贴图这件事拆到底:块压缩在内存里到底是怎么存的、ASTC/ETC2/BC 各家族的能力边界、各平台和图形 API 的强制性与可选性、Unity 加载时到底会产生几份数据、以及可以直接抄进项目的导入规范和 CI 门禁。

目录

章节 内容
先区分三层格式 源文件、构建容器、GPU 纹理格式,混在一起就会算错账
内存账怎么算 bpp、块对齐、Mip 系数,2048² 完整对照表
为什么会有这些格式,该用哪个 格式的成因、压缩的第一驱动力、平台选型速查
块压缩原理 为什么 GPU 能直接采样压缩数据,有损的代价是什么
尺寸与对齐:还需要 2 的次方吗 NPOT、块对齐、Mip 链,POT 建议到底还成不成立
桌面格式:BC 家族 BC1 到 BC7,通道和质量差异
移动格式:ETC / PVRTC / ASTC ETC1、ETC2、EAC、PVRTC、ASTC 逐个体检
平台与 API 支持矩阵 规范强制、硬件支持、各平台差异
Unity 的加载路径与内存副本 为什么不支持的格式会导致内存翻几倍
Crunch 是什么 磁盘压缩不是显存压缩:三层结构、crnlib、何时该用
导入设置逐项决策 Texture Importer 每一项该怎么定
工具链:怎么看真实内存 贴图 / Shader / 音效到底占多少,内置、厂商与开源工具清单
大厂工程规范 资源分级、导入强制、扫描门禁、多格式分发
常见坑与排障 一整张症状对照表
验收清单与交付物 落地时要交付什么

先区分三层格式

贴图讨论里最常见的错误,是把三个完全不同的东西当成一个东西。它们的度量单位、影响因素和优化手段都不一样。

例子 影响什么 不影响什么
源文件格式 PNG、JPG、PSD、TGA、EXR 仓库体积、导入耗时 运行时内存,几乎完全不相关
构建/分发容器 APK、AAB、AssetBundle、Addressables、LZ4、LZMA、Crunch 下载体积、磁盘占用、加载 IO 上传 GPU 后的显存占用
GPU 纹理格式 ASTC、ETC2、PVRTC、BC1-7、RGBA32 显存占用、内存带宽、采样质量、设备兼容 包体(间接相关)

由此可以推出几条必须记住的结论:

  • 一张 8KB 的 PNG 和一张 3MB 的 PNG,展开成 2048×2048 RGBA32 后都是 16 MiB。 压缩得再好也不会让显存变小。
  • AssetBundle 用 LZ4/LZMA 压得再小,解压后进显存还是那份 GPU 格式的数据。 容器压缩省的是下载和磁盘,不是显存。
  • Crunch 压缩只省磁盘,不省显存。 它是在 DXT/ETC 之上再加一层变码率有损压缩,Unity 加载时在 CPU 上解回 DXT/ETC 再上传 GPU,显存占用和未 Crunch 的完全一样(展开见 Crunch 到底是什么)。
  • 真正决定内存的是 GPU 纹理格式 + 分辨率 + 是否有 Mip + 是否多了一份 CPU 副本。

内存账怎么算

未压缩纹理

字节数 = 宽 × 高 × 每像素字节数 × Mip 系数

RGBA32 是 4 字节/像素,RGB24 是 3 字节,RGBA Half 是 8 字节。一张 2048×2048 的 RGBA32 基础层就是 16 MiB。

块压缩纹理

块压缩不是按像素存,而是按“固定尺寸的块”存。所以公式变成:

每层字节数 = ceil(宽 / 块宽) × ceil(高 / 块高) × 每块字节数

这一点非常关键,它带来两个工程后果:

  1. 尺寸必须尽量对齐块宽高。2048 对 ASTC 6×6 来说要 ceil(2048/6) = 342 个块,边缘块有大量无效像素被一起编码。尺寸越是对齐得差,浪费越大。
  2. 小图极其不划算。一张 7×7 的图用 ASTC 6×6 要占 2×2 个块共 64 字节,等效码率超过 10 bpp,比大图的 3.56 bpp 差了三倍。小图要尽量打进图集,而不是各自单独压缩。

Mip 系数

完整 Mip 链的总量是基础层的约 4/3(≈1.333)。但因为每一级都要至少占一个完整的块,实际会比 4/3 略大,小图和粗块尺寸(12×12)尤其明显。

以 2048×2048 用 ASTC 6×6 为例,逐级计算:

Mip 级别 尺寸 块数 字节
0 2048×2048 342×342 1,871,424
1 1024×1024 171×171 467,856
2 512×512 86×86 118,336
3 256×256 43×43 29,584
4 128×128 22×22 7,744
5 64×64 11×11 1,936
6-11 ≤32×32 逐级 832
合计 2,497,712 B ≈ 2.38 MiB

按 4/3 粗估是 2.38 MiB,实际完全吻合——不是巧合,而是大图上块对齐开销可以忽略。

2048×2048 完整对照表

格式 每块字节 等效 bpp 基础层 含完整 Mip
RGBA Half(HDR) 64 32.00 MiB 42.67 MiB
RGBA32(未压缩) 32 16.00 MiB 21.33 MiB
RGB24(未压缩) 24 12.00 MiB 16.00 MiB
BC7 / BC6H 16 8.00 4.00 MiB 5.33 MiB
BC3 / DXT5 16 8.00 4.00 MiB 5.33 MiB
BC5(法线) 16 8.00 4.00 MiB 5.33 MiB
ETC2 RGBA8(EAC) 16 8.00 4.00 MiB 5.33 MiB
ASTC 4×4 16 8.00 4.00 MiB 5.33 MiB
BC1 / DXT1 8 4.00 2.00 MiB 2.67 MiB
BC4 8 4.00 2.00 MiB 2.67 MiB
ETC2 RGB8 8 4.00 2.00 MiB 2.67 MiB
EAC R11 8 4.00 2.00 MiB 2.67 MiB
PVRTC 4bpp 4.00 2.00 MiB 2.67 MiB
ASTC 6×6 16 3.56 1.78 MiB 2.38 MiB
ASTC 8×8 16 2.00 1.00 MiB 1.33 MiB
PVRTC 2bpp 2.00 1.00 MiB 1.33 MiB
ASTC 12×12 16 0.89 0.44 MiB 0.59 MiB

换算直觉:ASTC 6×6 相对 RGBA32 是 1/9。也就是说,一张不小心回退成 RGBA32 的 2048² 图,等于九张正常 ASTC 6×6 图的内存。一张可能扛得住,几十张就是低端机 OOM。

分辨率的影响请务必记住:分辨率降一级,内存降为 1/4。降分辨率的收益永远大于在格式上抠 4×4 换 6×6(只有 2.25 倍)。

为什么会有这些格式,该用哪个

看到了上面那张表,很自然会冒出三个问题:为什么会有这么多种格式、什么设备用什么、为什么非得压缩。

格式这么多,是三条历史路径造成的

成因 具体表现 结果
硬件厂商先有电路 S3 的 S3TC(1998)、Ericsson 的 ETC1(约 2005)、Imagination 的 PVRTC(2007)、ARM 主导的 ASTC(2012) 格式跟着 GPU 阵营走,是先有硬件解码器、再有标准
两个 API 阵营各自锁定 Direct3D 走 BC 家族;OpenGL ES / Khronos 走 ETC1 → ETC2/EAC → ASTC 桌面和手机的格式互不通用,不是技术不行,是生态路线不同
目标差异需要多档 通道数(R / RG / RGB / RGBA)、动态范围(LDR / HDR)、质量与体积的权衡 一个格式覆盖不了所有组合,所以必须一族多档

压缩成一句话:块压缩格式是“硬件阵营 + API 生态”的产物,不是按画质排名排出来的。 这能解答两个常见困惑:

  • 手机为什么不用桌面那套 BC? 因为 BC 走的是 Direct3D 生态,移动端选了 Khronos 的开放标准路线。
  • 同一阵营为什么还要分这么多档? 因为需求不同:法线要双通道、Mask 要单通道、HDR 要独立格式,一个格式覆盖不了。

为什么要用:带宽和功耗才是第一驱动力

多数人把块压缩理解成“省显存”。在移动端,这个优先级是错的:

优先级 收益 说明
1 省带宽 移动 GPU 是 UMA 架构,CPU/GPU 共享同一块 DRAM,纹理采样是最大的带宽消耗方之一
2 省功耗 移动端 DRAM 读写量 ≈ 耗电与发热,少读数据就是省电、少降频
3 省显存 容量问题,但降分辨率的性价比更高
4 省磁盘 / 下载 由容器压缩和尺寸决定,与 GPU 格式是两条独立战线

所以拿到移动端,“我显存够大、不用压缩”是站不住的——真正的墙是带宽和功耗。从 8bpp 换到 3.56bpp,意味着采样同样多的纹素少读约 55% 的数据,这部分会直接变成帧率和续航。

平台选型速查

平台 首选 兜底 关键约束
桌面 PC BC7(按通道可降到 BC1 / BC5) BC3 / DXT5 完全不支持 ETC2 / ASTC
Android ASTC 6×6 ETC2 要靠 AAB 多格式分发
iOS ASTC 6×6 ETC2 LDR ASTC 需 A8+
主机 平台 SDK 专属格式 用厂商工具链,不要照搬移动结论
WebGL 2 / 小游戏 ETC2 ASTC 看设备扩展,必须逐平台验证

块压缩原理

固定尺寸块 + 定点解释

所有主流 GPU 压缩格式(BC、ETC、ASTC)都是固定码率块压缩

  • 把图像切成固定像素尺寸的块(BC/ETC 是 4×4,ASTC 是 4×4 到 12×12 可选)。
  • 每个块编码成固定字节数(BC1/BC4/ETC2_RGB8 是 8 字节,BC3/BC7/ETC2_RGBA8/ASTC 是 16 字节)。
  • 块内像素通过“端点色 + 索引表 / 权重”的方式近似重建,而不是存原始像素。

ASTC 6×6 就是“一个 128 bit 的块表示 36 个像素”,所以:

bpp = 128 / (块宽 × 块高)

这是码率定义,不是编码质量档位。4×4 和 6×6 是同一个硬件格式族下不同的存储密度,不是“高质量/低质量”预设。

为什么 GPU 能直接采样

因为块是固定尺寸、固定字节,纹理硬件可以直接用块索引算出地址,解码在纹理单元里用固定流水线完成:

blockAddress = baseAddress + (blockY × blocksPerRow + blockX) × blockBytes

不存在“每帧解压整张图”这件事。压缩数据就是常驻显存的数据本身。这也解释了为什么块压缩格式的内存占用是精确可算的,而不像 JPEG 那样随内容浮动。

顺带澄清一个流传很广的疑虑:ASTC 6×6 的“6”不是 2 的幂,这不是缺点。ASTC 规范里本来就有 5×4、5×5、6×5、6×6、8×5、8×6、10×5、10×6、10×8、12×10 等一堆非 2 次幂块。纹理宽高是 2 次幂的老建议说的是整张图的尺寸,不是块的尺寸。硬件支持 ASTC LDR 时,标准块尺寸就是整套可用的。

有损的代价

块压缩全都是有损的,表现形式比 JPEG 更“结构化”:

现象 成因 高发资源
块状伪影 / 色块 块内只有有限端点色,跨块不连续 渐变背景、天空、大面积纯色过渡
色带 / 台阶 位深不足 深色渐变、雾效、半透明遮罩
边缘发糊、串色 块跨越了高频边界 UI 细线、文字、图标描边
Alpha 边缘锯齿或变硬 Alpha 与颜色挤在同一块里 半透明特效、抠图立绘边缘
法线光照畸变 法线对误差极敏感 高光、金属、近景法线贴图
遮罩阈值翻转 通道承载数值而非颜色 ORM/PBR 通道包、Mask、SDF

最后两条是资深和新手的分界线:颜色贴图压缩误差可以被美术掩盖,法线和 Mask 的误差会变成逻辑错误。如果 shader 里对某个通道做 step(0.5, x) 之类的阈值判断,压缩误差可能直接让分支走错。

sRGB 与 Linear

颜色贴图走 sRGB(Unity 的 sRGB (Color Texture) 勾选),法线、粗糙度、金属度、Mask 必须走 Linear。sRGB 贴图在采样时由硬件做反 sRGB 转换,压缩格式也分 sRGB / Linear 变体(例如 ASTC_6x6ASTC_6x6_HDR、BC7 的 SRGB 变体)。

sRGB 勾错的典型症状:整体偏亮/偏暗、光照能量不对。而且它极难在编辑器里看出来——因为编辑器里很多贴图根本没走压缩路径。

尺寸与对齐:还需要 2 的次方吗

先给结论

不需要。 ASTC、ETC2、BC 都支持任意宽高(NPOT)。块压缩的公式本身就是 ceil(宽 / 块宽) × ceil(高 / 块高),边缘不足一个块就补满一个块。“贴图必须是 2 的次方”是 OpenGL ES 2.0 时代的遗留经验,不是当前硬件的要求。

但“不强制”不等于“没有代价”。要先把三件常被混为一谈的事拆开:

对象 是否必须 2 的次方 说明
块的尺寸 ASTC 有 5×5、6×6、8×5、10×6、12×10 一堆非 2 次幂块,这是规范设计,不是缺陷
整图尺寸 ASTC / ETC2 / BC 都支持 NPOT(PVRTC 是唯一例外)
Mip 链的每一级 每级先减半、再按块宽取整,所以每级都是独立对齐的

“2 的次方”这条建议真正还有价值的场景只剩两个:老 API / 老设备(见下表),以及图集尺寸(便于幂等切分和压缩器优化)。

谁还在要求尺寸

约束 来源 具体规则
PVRTC 必须 POT 格式规范 宽高都必须是 2 的次方,否则 Unity 会重新缩放或回退。这是 PVRTC 独有的硬约束
WebGL 1.0 / ES 2.0(无 OES_texture_npot 规范 NPOT 纹理不能用 Mip、不能用 Repeat 采样。现代浏览器基本已不受限
D3D11 及更早:BCn 的 level 0 宽高必须是 4 的倍数 规范 D3D12 + 新 Agility SDK 已放开(UnalignedBlockTexturesSupported
部分老 GPU 驱动的行 / 面 对齐要求 驱动 历史上有些移动 GPU 对压缩纹理有对齐要求,现代设备基本已无
Unity 的 Non-Power of 2 选项 引擎 可选 None / ToNearest / ToLarger / ToSmaller,默认 None(不缩放)

Vulkan 与 Metal 对 NPOT 压缩纹理没有额外约束,现代 Android / iOS 设备可以放心用。

尺寸不对齐的代价:边缘填充

每一级的块数是 ceil(w / blockW) × ceil(h / blockH),最后一行 / 列不足一块时整块照占:多出来的纹素永远不会被采样,但确实占内存。

以 100×100 为例:

格式 块数 实际覆盖 有效利用率
ASTC 4×4 25×25 = 625 100×100 100%(正好整除)
ASTC 6×6 17×17 = 289 102×102 96.1%
ASTC 8×8 13×13 = 169 104×104 92.5%
ASTC 12×12 9×9 = 81 108×108 85.7%

结论:块越大,尺寸不对齐的浪费越明显;但即使 12×12 也只有约 14%,远小于“降一级分辨率省 75%”。所以不要为了省内存去纠结 POT,该纠结的是分辨率。

真正需要警惕的是小图:一张 20×20 用 ASTC 6×6 是 4×4 = 16 块共 256 字节,等效 5.12 bpp,比大图的 3.56 bpp 还贵。小图应该进图集。

Mip 链在 NPOT 下会怎样

Mip 的尺寸是 floor(w / 2^level)——先减半,再按块宽向上取整。所以 NPOT 图会更“早”地落进小块数区间,低级别 Mip 的相对浪费会突然变大:

  • 2048² 的 ASTC 6×6:逐级块数从 342×342 一路降到 1×1,最后几级的开销可以忽略。
  • 1000² 的图:Mip 链是 1000 → 500 → 250 → 125 → 62 → 31 → 15 → 7 → 3 → 1,每一级都要按 6 取整,在 31 → 15 → 7 → 3 这几级上浪费占比明显上升。

这也解释了为什么“源图尽量取 2 的次方,或至少取块尺寸的倍数”这条建议仍然有人提:它优化的不是显存公式,而是浪费比例和 Mip 链的整洁度。 另外注意:尺寸不会因为 Mip 生成而“自动变回 POT”,floor 之后各平台行为一致,不存在某一级被偷偷补到 POT 的情况。

工程建议

场景 建议
3D 贴图 保持 2 的次方,或至少对齐到块尺寸(用 ASTC 6×6 就取 6 的倍数),让 Mip 链干净
UI / 精灵 不必追求 POT,但必须打进图集;图集尺寸取 2 的次方便于管理
图集内 padding 至少留一个块宽(ASTC 6×6 就留 6–8 px),否则相邻图会在边缘块里互相串色
尺寸不是 4 的倍数 BC 系在 D3D11 及更早会出问题,扫描器里按警告处理,不阻断
PVRTC 目标 必须是 POT,否则被缩放或回退——这也是新项目可以直接放弃 PVRTC 的原因之一
很扁 / 很长的 NPOT 图 检查块浪费比例,必要时裁到块尺寸的倍数
图集尺寸 取 2 的次方只是为了切分和工具友好,与显存无关

一句话:POT 是“兼容性”和“整洁度”的要求,不是“内存”的要求。 决定内存的始终是分辨率、bpp 和副本数。

桌面格式:BC 家族

桌面(Windows / macOS / Linux / 主机)走 DirectX/OpenGL/Vulkan,主流是 BC(Block Compression),也就是老名字 DXT/ATI 的后继。

格式 别名 每块字节 bpp 通道与用途 说明
BC1 DXT1 8 4 RGB + 1bit Alpha 最省,无渐变 Alpha;有透明区会有硬边
BC2 DXT3 16 8 RGBA,Alpha 4bit 已基本被 BC3 取代
BC3 DXT5 16 8 RGBA,Alpha 插值 通用带 Alpha 方案,兼容性最好
BC4 ATI1 8 4 单通道 灰度、Mask、单通道数据
BC5 ATI2 16 8 双通道 法线贴图首选,XY 双通道,质量优于 BC3
BC6H 16 8 HDR RGB HDR 环境、天空盒、Lightmap
BC7 16 8 RGB / RGBA 质量最好的 8bpp 方案,压缩慢

桌面选型规则:

  • BC1 覆盖不到的所有情况,默认 BC7。 HB2 之后的显卡几乎都支持 BC7,质量和 BC3 差距明显,同为 8bpp 不涨内存。
  • 法线贴图一律 BC5,不要用 BC1/BC3 存法线。BC5 把 X/Y 单独编码,Z 在 shader 里重建,质量高出一个档次。
  • 只有需要兼容 DirectX 10 级老卡(2012 年以前的集显)时才退回 BC3。 现代项目不应该为此牺牲画质。
  • BC6H 只用于 HDR,普通 LDR 贴图不要用。

移动格式:ETC / PVRTC / ASTC

ETC1

  • 1998 年 Ericsson 方案,所有 Android 设备从 2.2 起保证支持(OpenGL ES 2.0 的可选但事实普及格式)。
  • 4bpp,只有 RGB,没有 Alpha
  • 曾经的用法是“ETC1 + 单独一张 Alpha 图”(Alpha Splitting)。在 ETC2 普及的今天已无必要,只作为最古老的兜底。

ETC2 / EAC

  • OpenGL ES 3.0 的强制格式,也就是说 WebGL 2、所有 Vulkan 设备、所有支持 ES 3.0 的设备都保证支持。这是移动端最安全的基线。
  • 五个成员:
    • ETC2 RGB8:4bpp,RGB 无 Alpha。
    • ETC2 RGB8 Punchthrough:4bpp,RGB + 1bit Alpha(硬边透明)。
    • ETC2 RGBA8(也叫 ETC2_EAC):8bpp,带插值 Alpha。
    • EAC R11:4bpp,单通道。
    • EAC RG11:8bpp,双通道(法线可用)。
  • 代价:带 Alpha 的图要么用 8bpp 的 RGBA8(和 BC7 一样大),要么用 1bit Alpha 牺牲边缘质量。早期 ETC2 硬件上 RGBA8 的质量明显弱于 BC7。

PVRTC

  • 只用于 iOS(PowerVR / Apple GPU)。
  • 2bpp / 4bpp 两种码率,属于小波类,不是块压缩。
  • 约束较多:要求 2 次幂尺寸(否则 Unity 会重新缩放或回退),有最小尺寸限制(4bpp 约 8×8,2bpp 约 16×8),低分辨率 Mip 需要按最小尺寸补齐。
  • 质量特点是整体模糊而不是块状伪影,文字和细线表现较差。
  • 现在的定位:只在需要兼容 A7 及更早、或者要极致省内存的场合用。新项目可以直接忽略 PVRTC

ASTC

移动端现在的首选。核心优势:

  • 块尺寸从 4×4 到 12×12 共 14 种,码率 8bpp 到 0.89bpp 细粒度可调,每个块恒定 128 bit。
  • RGB 和 RGBA 占用完全相同的块空间(不像 ETC2 从 4bpp 跳到 8bpp)。这意味着“需要 Alpha 所以要双倍内存”这件事在 ASTC 上不存在。
  • 支持 LDR 和 HDR(HDR 是独立能力,见下)、2D 和 3D。
ASTC 块 等效 bpp 2048² 基础层 典型起点用途
4×4 8.00 4.00 MiB 文字、SDF、细节 UI、重要法线、Mask
5×5 5.12 2.56 MiB 高质量主角、近景物件
6×6 3.56 1.78 MiB 通用起步档:角色、场景、精灵
8×8 2.00 1.00 MiB 远景、模糊背景、低频大图
10×10 1.28 0.64 MiB 激进压缩
12×12 0.89 0.44 MiB 极限压缩,可下载内容

需要警惕的点:

  • 不要全局锁死 6×6。 文字、细线图标、SDF 字体、近景法线、通道 Mask 都应该往上走 4×4/5×5;“背景”也不能无脑上 8×8,如果相机能拉近,块状伪影会很显眼。
  • ASTC 支持 NPOT 尺寸,但不代表小图省空间。 小图还是打包进图集更划算。
  • ASTC LDR 和 ASTC HDR 是两个独立能力。 Apple 硬件上 LDR 从 A8(Apple2 GPU 家族)开始,HDR 要到 A13(Apple6 家族)才开始。HDR 回退的代价比 LDR 更大(可能扩成 RGBA Half)。

平台与 API 支持矩阵

这一节是本文最需要背下来的部分。“通常支持”和“规范强制”是两回事,把后者当前者会导致线上回退事故。

各图形 API 的强制性

格式族 OpenGL ES 2.0 OpenGL ES 3.0 OpenGL ES 3.2 Vulkan OpenGL 4.2 Direct3D 11
ETC1 事实普及 支持 支持 不支持 不支持 不支持
ETC2 / EAC 强制 强制 可选特性 textureCompressionETC2 强制(4.3 起) 不支持
PVRTC 仅 PowerVR/Apple 同左 同左 不支持 不支持 不支持
ASTC LDR 2D 扩展 扩展(GL_KHR_texture_compression_astc_ldr 强制 可选特性 textureCompressionASTC_LDR 扩展 不支持
BC1-5 不支持 不支持 不支持 可选特性 textureCompressionBC 支持 强制(feature level 9.1+)
BC4/BC5 不支持 不支持 不支持 同 BC 支持 强制(10.0+)
BC6H / BC7 不支持 不支持 不支持 同 BC 4.2 起 BPTC 强制(11.0)

必须注意的推论:

  • OpenGL ES 3.0 ≠ ASTC 可用。 ES 3.0 只保证 ETC2。ASTC 在 ES 3.0/3.1 上是通过扩展暴露的,不能从 API 版本推断。
  • Vulkan ≠ ASTC 可用。 Vulkan 里 ASTC 是 core 里的可选特性,必须运行时查询 textureCompressionASTC_LDR
  • ES 3.2 才把 2D LDR ASTC 写入强制列表,但也不代表 HDR ASTC。

设备与 GPU 硬件

块压缩不是软件选项,而是烧在 GPU 硅片里的解码电路。所以问题不是“哪些设备用块压缩”——只要做实时渲染的设备都在用——而是“支持哪几种”。

块压缩进入硬件的时间比多数人想的早:

年份 格式 里程碑
1998 S3TC / DXT1 S3 的 Savage3D 首个硬件实现;同年微软授权进 DirectX 6.0,成为桌面标准
约 2005 ETC1 Ericsson 方案被 Khronos 采纳,Android 2.2 起事实普及
2007 PVRTC PowerVR 专有格式,初代 iPhone 开始用
约 2010 BC6H / BC7 Direct3D 11 硬件
2012 ETC2 / EAC OpenGL ES 3.0 强制
2012 ASTC Khronos 发布;2015 年被 OpenGL ES 3.2 纳入强制(2D LDR)

设备与 GPU 的实际支持情况:

设备 / GPU 必定支持 通常支持 不支持
桌面 PC(NVIDIA / AMD / Intel) BC1–BC3(D3D 9.1 级) BC4/BC5、BC6H/BC7 ETC2、ASTC、PVRTC 全不支持
Android(Adreno / Mali / PowerVR) ETC2 + EAC ASTC;PVRTC 仅 PowerVR 机型 BC 家族
iOS(Apple GPU) ASTC(A8+,LDR) ETC2(A7+)、PVRTC(历史遗留) BC 家族
主机 厂商专属格式 通常也支持 BC
WebGL 2 / 小游戏 ETC2 ASTC(看设备扩展) 视平台而定

两条最容易踩的推论:

  • 桌面 GPU 完全不支持 ETC2/ASTC,手机 GPU 完全不支持 BC。 两边资源不能混用,AssetBundle 必须按平台分开打。
  • PVRTC 不是通用移动格式。 Android 生态里只有用 PowerVR 的机型支持,它实际只能当 iOS 专用格式。

“设备决定格式”之所以绕不过去,是因为解码电路在硅片里:Adreno 的纹理单元里没有 BC 解码器,给它一张 BC7 不会报错,而是在 CPU 上软解成 RGBA32(代价见 不支持的格式会怎样)。

反过来,也有些数据不走块压缩

不走块压缩的 原因
RenderTexture / FrameBuffer 块压缩纹理不可写入,GPU 无法把渲染结果渲染进 BC/ASTC 纹理
深度 / 模板缓冲 没有对应的块压缩格式
Compute / UAV 输出 需要可读写
Read/Write 的 CPU 副本 要给 CPU 读写,必须是可读的原始像素
运行时 LoadImage 的 PNG 解出来就是 RGBA32,不会自动变 ASTC

所以完整图景是:静态贴图走块压缩省显存,动态渲染目标走未压缩因为要可写。 这也是 RenderTexture 特别吃内存的根因。

iOS

Apple GPU 家族 代表 SoC LDR ASTC HDR ASTC
Apple1 A7
Apple2+ A8 起
Apple6+ A13 起
  • A8 是硬件边界,不是发版边界。 实际发版设备集是 Unity Player 要求、Deployment Target、Xcode/SDK、App Store 规则的交集。现代项目实际上已经不覆盖 A7 了。
  • Apple 平台还支持 ETC2(A7 起),所以“ASTC 为主 + ETC2 兜底”在 iOS 上也是成立的。
  • 如果确认发版设备全在 A8 以上,iOS 直接用 ASTC 是自然选择。

Android

Android 的支持性取决于 GPU 和驱动,而不是 Android 版本号。不要写“Android 8 以上支持 ASTC”这种判断。

格式 Google Play 生态覆盖(官方粗略值) 常见 GPU 门槛
ETC2 > 95% ES 3.0 / Vulkan 设备
ASTC > 80% Adreno 4xx+ / 骁龙 415+、Mali T624+、Tegra K1+、PowerVR GX6250+

为什么“纯 ASTC 策略”行不通

把上面那句结论拆开看:覆盖率高,不等于可以只押一个格式。

> 80% 的正确读法

读法 含义
乐观读法(错) “八成设备支持,那用 ASTC 就行了”
正确读法 反过来说是最多 20% 的设备不支持,这不是一个小数字

而且要补两个限定:

  • 这是 Google Play 全生态的数字,不是你的用户群的数字。 你的真实分布取决于目标市场和机型策略。要看自己的分布,用 Play Console 的 Reach and devices,按 SoC、GPU、RAM 维度拆开看。
  • 低端机在用户量上往往是多数。 尤其在增量市场,一台几年前的中低端机可能贡献了可观的 DAU,而它恰好落在 ASTC 覆盖之外的那部分里。

不支持时会发生什么:两条都不好的路

Google Play 判断设备能力的方式,是读设备的 OpenGL 扩展串与版本号:astc 对应 GL_KHR_texture_compression_astc_ldretc2 则要求 OpenGL ES 3.0 及以上。

只提供 ASTC 时,那部分设备会遇到两种情况之一:

路径 触发条件 后果
A. 商店直接过滤 Manifest 里声明了 <supports-gl-texture>,且设备不支持其中任何一项 用户在 Play 里看不到这个应用,直接损失用户,但至少不会崩溃
B. 能装,运行时软解 没做格式过滤。AAB 里只有 ASTC,Play 按“都不支持就下发默认格式”的规则下发的还是 ASTC Unity 把纹理解压成 RGBA32内存约 9 倍、加载明显变慢

路径 B 才是真正危险的,因为它是一个“装了才发现”的故障:用户成功安装,然后在低端机上 OOM 崩溃——而低端机正是内存最小、最扛不住 9 倍膨胀的那批设备。同一个用户群既是最可能没 ASTC 的,又是最容易被回退拖垮的,这是双重惩罚,代价直接体现在崩溃率和评分上。Android 官方文档也专门提示了这个内存转码的代价。

AAB 多格式分发如何解决

核心机制一句话:AAB 里同时打包 ASTC 与 ETC2 两套纹理,Google Play 在安装时按设备能力只下发匹配的那一套。

环节 结果
AAB 本体(你上传的) 含两套纹理,体积变大
Optimized APK(用户下载的) 只含一套,下载体积基本不增加
构建时间 / CI 存储 变长、变大
QA 成本 两套切片都要验,画质要分别看

这里有个关键澄清,能消除很多人对多格式的抵触:多格式不等于用户下载体积翻倍。 体积代价落在你上传的 AAB 和构建流水线上,不落在用户手机上。真正的代价是构建耗时、CI 存储和翻倍的 QA 工作量——这些是可以用工程手段摊平的,而丢掉那 20% 的用户是丢掉的。

兜底不是免费的:要认 ETC2 的取舍

ETC2 不是无代价的兜底,它是明确的权衡:

  • 带 Alpha 的贴图要 8bpp(和 BC7 一样大),质量还不如 ASTC 6×6;
  • 想省内存就只能用 1bit Alpha,牺牲半透明边缘。

也就是说,兜底那批用户的画质和内存都吃亏。这是有意的选择:宁可让这部分设备跑得差一点,也不要让他们崩溃或者根本装不上。

什么情况下可以只用一种格式

单一格式不是绝对禁止,前提是你能确证设备集合

  • 自研渠道、企业内部分发、定向设备群(统一定制机、云游戏实例、展陈设备等);
  • 能通过埋点或后台数据确认线上 100% 设备支持 ASTC;
  • 或者反过来:明确接受纯 ETC2(覆盖 > 95%),用画质换简单。

面向公开市场的买量产品,AAB + ETC2 兜底 + ASTC 优化就是默认答案。

桌面 / 主机 / Web

  • 桌面:BC 一家独大。D3D 从 feature level 9.1 就强制 BC1-3,11.0 起有 BC6H/BC7;Vulkan 上查 textureCompressionBC;OpenGL 4.2 起有 BPTC。PC 项目不应该出现 ASTC/ETC2 资源
  • 主机:各平台有专属格式与工具链,通常走厂商 SDK 的纹理打包工具,本文不展开。
  • WebGL / 小游戏:WebGL 2 对应 ES 3.0,ETC2 是可靠基线;ASTC 只在部分浏览器/设备通过扩展暴露。微信小游戏等平台还有各自的纹理格式与分包限制,要单独验证,不能直接套用 Android 结论。

Unity 的格式查询与回退

运行时可以做能力自检:

using UnityEngine;

public static class TextureFormatDiagnostics
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
    private static void LogSupport()
    {
        Debug.Log(
            $"GPU={SystemInfo.graphicsDeviceName}, " +
            $"API={SystemInfo.graphicsDeviceType}, " +
            $"ASTC4x4={SystemInfo.SupportsTextureFormat(TextureFormat.ASTC_4x4)}, " +
            $"ASTC6x6={SystemInfo.SupportsTextureFormat(TextureFormat.ASTC_6x6)}, " +
            $"ASTC8x8={SystemInfo.SupportsTextureFormat(TextureFormat.ASTC_8x8)}, " +
            $"ETC2RGBA8={SystemInfo.SupportsTextureFormat(TextureFormat.ETC2_RGBA8)}, " +
            $"BC7={SystemInfo.SupportsTextureFormat(TextureFormat.BC7)}");
    }
#endif
}

要理解这个 API 的边界:它只证明“这台设备的这个图形环境支持该格式”,不证明当前加载的某个资源现在真的以该格式驻留。要验证后者,必须结合 Texture2D.format / Texture2D.graphicsFormat + Memory Profiler + 实际下发的分包。

需要区分 sRGB/Linear、LDR/HDR、具体用途时,用更精确的 SystemInfo.IsFormatSupported(GraphicsFormat, GraphicsFormatUsage)

Unity 的加载路径与内存副本

这一节解释“为什么配置看起来没问题,真机内存却爆了”。

从磁盘到显存

源文件(PNG/PSD)
   ↓ 导入时按平台压成 GPU 格式(ETC2/ASTC/BC…)
AssetBundle / 构建数据(压缩块数据,容器再套 LZ4/LZMA)
   ↓ 加载:解容器压缩
CPU 侧原生内存(该纹理对象持有的压缩块数据)
   ↓ 上传
GPU 显存(纹理对象,渲染时直接采样)

关键在于:CPU 侧和 GPU 侧各有一份

三份数据,别搞混

数据 位置 何时存在 怎么消掉
Bundle 解压后的数据 托管/原生内存 Bundle 加载后到 Unload AssetBundle.Unload / Addressables 释放句柄
纹理的 CPU 副本 原生内存 Read/Write Enabled 开启时 关闭 Read/Write,或 Apply(true, true)
纹理的 GPU 数据 显存 纹理被创建/上传后 释放纹理引用 + 卸载

不支持的格式会怎样(最容易出事故的一环)

Unity 官方文档写得很明确:

当 Unity 加载一张压缩格式不被设备支持的纹理时,它会把纹理解压成该平台的默认未压缩格式,并把未压缩副本和原始压缩纹理一起保存在内存中

这意味着一次错误的格式配置,代价是:

  1. 内存成倍增长。ASTC 6×6(3.56bpp)回退到 RGBA32(32bpp)是 约 9 倍。2048² 从 2.38 MiB 变成 21.33 MiB。
  2. 加载时多出 CPU 解压开销,表现是加载卡顿、切场景变慢。
  3. 带宽压力暴涨,低端机直接 OOM 或掉帧。
  4. 两份数据同时存在,峰值内存比稳态更糟。

所以:运行时回退只能当最后的安全网,绝不能当正常发版路径。

Read/Write Enabled 的代价

Read/Write Enabled 勾上,Unity 就在 CPU 内存里保留一份可读像素数据:

  • 内存大约翻倍(对压缩纹理是“压缩块数据 + 解压后的可读数据”,可能更糟)。
  • 让 Streaming Mipmap 失效——因为可读副本需要所有 Mip 都在,所以整张图会被完整加载。
  • 运行时创建的可读纹理,处理完应立即释放:
var tex = new Texture2D(width, height, TextureFormat.RGBA32, false);
tex.LoadImage(rawBytes);
// … 处理像素 …
tex.Apply(updateMipmaps: true, makeNoLongerReadable: true); // 上传 GPU 并删除 CPU 副本

注意 makeNoLongerReadable: true 之后纹理不能再从磁盘重新加载,也会脱离 Mip 限制设置,所以必须确保上传时是期望的完整分辨率。

Crunch 到底是什么

一句话:Crunch 是“存在磁盘上的那层额外压缩”,不是一种 GPU 纹理格式。 它不是 BC / ETC / ASTC 的替代品,而是叠加在这些格式之上的一层可变码率有损压缩

它在三层结构里的位置

未用 Crunch 用了 Crunch
构建产物 / 下载体积 固定码率块数据(如 ETC2 4bpp) 变码率压缩数据,通常在 1–2 bpp 量级
加载过程中的 CPU 内存 该格式的压缩块数据 先短暂持有 Crunch 数据,再在 CPU 上解成 DXT / ETC
上传后的显存 DXT / ETC 块数据 完全一样的 DXT / ETC 块数据

所以关键点是:GPU 的解码电路不认识 Crunch,只有 CPU 认识。 GPU 拿到的永远是解压后的 DXT / ETC。

技术来源:为什么它能压得比固定码率更小

Unity 的 Crunch 来自 Rich Geldreich 的 crnlib(Binomial LLC / crunch 项目)。核心设计是可转码(transcodable):它的码流设计的终点不是“解出一张 RGB 图”,而是直接转码成 DXT1 / DXT5 / BC4 / BC5 / ETC 的块数据,中间不需要重建整张图像。

手段 作用
端点聚类(endpoint clustering) 全图共用 DXT 端点调色板的统计特征
选择器向量量化 + 码本 把每个 4×4 块的选择器索引整体编码,而不是逐块存
熵编码 消除剩余统计冗余
变码率 / 率失真优化 按块的视觉重要性与内容复杂度分配比特:平缓区域几乎不占空间,高频区域才多给比特

官方给的经验量级:普通贴图约 1–1.25 bit/texel,法线贴图约 1.75–2 bit/texel(法线更难压)。注意这是内容相关的,不是固定值——同一张图在不同质量档下压出来的尺寸不同。

收益与代价

维度 Crunch 的效果
磁盘 / 下载体积 显著减小(这是它唯一的收益)
显存 / 运行时内存 完全不变
加载 CPU 开销 增加一次 CPU 解码(多线程),低端机上体现为加载卡顿
Streaming Mipmap 不支持,整张图从磁盘完整加载
导入 / 构建耗时 大幅增加,Crunch 编码很慢
画质 多一层有损;法线、遮罩这类数值型通道风险更高
平台兼容 只在支持 Crunch 的平台上生效,其他平台会退回普通 DXT / ETC

还有一个容易被忽略的组合:Crunch + AssetBundle 是三层压缩叠加——Bundle 的 LZ4 / LZMA、Crunch 本身、底层的 DXT / ETC。下载时确实小,但加载时要依次剥掉三层,加载耗时和峰值内存都不便宜。

什么时候该用

Crunch 是包体 / 下载优化手段,不是内存优化手段。

  • ✅ 值得用:下载体积严重超标、且该图不在加载关键路径上(预下载资源、低频大图、C 级背景)。
  • ❌ 不要用:首包关键路径、需要 Streaming Mipmap 的大图、法线贴图和 Mask、需要快速迭代的资源。
  • ⚠️ 想省显存请改分辨率或格式,这两项才是内存杠杆;Crunch 一点都不省。现代 SSD 和高速网络下,它“省加载时间”的价值也基本消失了。

Streaming Mipmap

  • 开关在 Quality Settings → Texture Streaming,单张纹理在导入设置里勾 Streaming Mipmaps
  • 运行时按相机距离只把需要的 Mip 上传 GPU,内存预算由 QualitySettings.streamingMipmapsMemoryBudget 控制。
  • 可以主动干预:
// 强制某张纹理保持低分辨率,常用于过场/远景
tex.requestedMipmapLevel = 2;
// 或者临时解除限制
tex.ClearRequestedMipmapLevel();
  • 注意:显存省了,但加载逻辑不是渐进的——当相机靠近需要更高 Mip 时,Unity 会重新读取并上传整张纹理,而不是“只补 mip 0”。所以近距离突进仍可能有卡顿。
  • 自定义 shader 里改了 UV 的纹理,Streaming 系统算不准,需要手动指定 requestedMipmapLevel

编辑器 ≠ 真机

这是排查贴图内存问题时最浪费时间的地方:

  • 编辑器里 Texture2D.format 经常报告 RGBA32,因为编辑器平台通常不套用移动端的压缩格式(也可以用 Build Profiles → Asset Import Overrides → Texture Compression 强制)。
  • 编辑器不会做 Android 的 ETC2 fallback,也不会做 AAB 的 Texture Compression Targeting 分包。
  • 编辑器的显存和真机 UMA 架构的内存行为不同。

结论:所有贴图内存结论必须在真机 + 实际下发的包上验证。

导入设置逐项决策

Default 页

设置 决策
Texture Type Sprite 给 UI/2D;Normal map 给法线(会自动关 sRGB);Default 给其它
sRGB (Color Texture) 颜色贴图 ;法线、粗糙度、金属度、Mask、灰度数据
Alpha Source From Gray Scale / Input Texture Alpha。没有 Alpha 通道就别留,能省一半
Alpha Is Transparency 半透明边缘要开(会做边缘膨胀,避免采样串色)
Read/Write Enabled 默认全关,只在需要 CPU 读写像素时开
Generate Mip Maps 3D 物件 ;UI / 2D 精灵 / 全屏图 (省 1/3)
Streaming Mipmaps 大图 ;UI
Max Size 按实际最大显示尺寸倒推,这是收益最大的一项
Compression None / Low / Normal / High。移动端建议用 Platform Override 显式指定格式,不依赖 Normal/High 的自动映射
Use Crunch Compression 默认 ,只对下载体积敏感资源开
Non-Power of 2 ToNearestNone。PVRTC 必须 POT;ASTC/ETC2 支持 NPOT,但块对齐仍有浪费(见 尺寸与对齐

Unity 默认格式映射(Default 页的 Compression 档位 → Platform Override 里的默认格式):

平台 通道 无压缩 Normal High Low
Windows/Linux/macOS RGB RGB 24 DXT1 BC7 DXT1
Windows/Linux/macOS RGBA RGBA 32 DXT5 BC7 DXT5
iOS RGB / RGBA RGB(A) 32 ASTC 6×6 ASTC 4×4 ASTC 8×8
Android RGB / RGBA RGB(A) 32 ASTC 6×6 / ETC2 ASTC 4×4 / ETC2 ASTC 8×8 / ETC2

注意 Android 一行的两层含义:Unity 会同时生成 ASTC 和 ETC2 两套数据,运行时按设备能力挑一套。这会让包体增大,但避免了运行时回退。

Platform Override 页

真正需要精确控制格式时,必须逐平台显式覆盖(overridden = true),并注意:

  • Android 的 Override ETC2 fallback:控制设备不支持 ETC2 时解压成什么(32 位 / 16 位 / 32 位降分辨率)。这是最后兜底,不是常规路径。
  • Ignore Platform Support:勾上会绕过编辑器的支持性检查,把不支持格式塞进包——通常只在自研加载器或确实做了运行时判断时用。
  • Android 的 Texture Compression Formats:Player Settings 里的列表,第一个是默认。配合 AAB 才有多格式分发;打 APK 时只用第一个。

Android AAB 的 Texture Compression Targeting

这是 Android 上最有价值的工程配置,因为它让 Google Play 按设备能力下发最优格式,而不是全靠 ETC2。(为什么必须同时准备两种格式,见 为什么“纯 ASTC 策略”行不通

推荐顺序:

1. ETC2   // 默认兜底,覆盖 >95%
2. ASTC   // 优化格式,覆盖 >80%

为什么 ETC2 放第一? 因为第一项是“默认兜底”而不是“优先格式”。Play 仍会给支持 ASTC 的设备下发 ASTC 切片,同时 ETC2 保证没有 ASTC 的设备拿到可用格式。

配置要点与坑:

  • 必须 AAB(或导出 AAB 用的 Gradle 工程)才生效;APK 只用第一项。
  • 启用 targeting 后,Unity 会忽略 Android 的 Texture Compression 构建设置,不能用它再覆盖。
  • 构建配置里要把 Asset Import Overrides → Texture Compression 设成非 Force Uncompressed
  • 自定义 Gradle 模板必须包含 texture { enableSplit = true },否则会出现“能装包但引擎初始化失败”(Unable to initialize the Unity Engine)这类问题——从旧 Unity 升级的项目特别容易踩,因为 Gradle 模板不会像 C# 脚本那样自动升级。
  • 逐纹理的 Platform Override 会覆盖全局策略和 Play 的分发选择,审计时必须一起查。
  • 远端 Addressables / 自建 CDN 的 Bundle 不受 targeting 保护。 如果远端也要分格式,必须把格式编进 Profile、RemoteLoadPath、catalog、Bundle 名、URL 和缓存 key,当成两条独立内容线维护。不要把不同二进制内容发在同一个 URL 后面。

按资源角色的格式与尺寸建议

资源角色 起步格式 检查什么
通用角色/场景反照率 ASTC 6×6 脸部、剪影、Alpha 边缘
主角 / 高质量 UI ASTC 4×4–5×5 细线、眼睛、文字、渐变
UI 背景 / 大面板 ASTC 6×6–8×8 色带、圆角、半透明
远景 / 模糊背景 ASTC 8×8 相机拉近时的块状伪影
法线贴图 ASTC 5×5–6×6(桌面 BC5) 光照畸变,确认 sRGB 关闭
ORM / Mask 通道包 ASTC 4×4–6×6 分通道可视化检查,阈值行为
字体 / SDF ASTC 4×4,或对比未压缩 小字号可读性、边缘
极小贴图 优先打进图集 块对齐浪费、Mip 数量
HDR 环境 / Lightmap 桌面 BC6H;移动确认 ASTC HDR 能力 独立能力,别和 LDR 混为一谈

工具链:怎么看贴图 / Shader / 音效的真实内存

原理讲完了,接下来是“怎么拿到证据”。这一节按测什么分类,而不是按厂商分类——因为同一个工具往往只能回答其中一两问。

先分清五类指标

想知道什么 典型问题 用什么
整机 / 进程内存 低端机会不会 OOM?崩溃前峰值多少? PerfDog、UWA GOT、dumpsys meminfo、Xcode Instruments / VM Tracker
引擎侧分类内存 贴图、网格、Shader、音频各占多少? Unity Profiler → Memory 模块;Memory Profiler 快照的汇总页
单个资源 这张图实际是 ASTC 还是被回退了?被谁持有? Memory Profiler 快照的 Textures 表 + 引用链
单帧 GPU 侧 这一帧的 draw call、RT、带宽来自谁? RenderDoc、Xcode Metal Debugger、Android Performance Analyzer、Snapdragon Profiler
构建产物 这个包 / AB 里到底装了什么、重复了多少? Build Report Inspector、AssetBundle Browser、Addressables Analyze

一个前提必须记住:编辑器里的数字不代表真机。贴图格式、显存行为、系统内存口径全都不一样。下面所有“单资源”级别的结论都要在真机 Development Build 上取。

Unity 内置(首选,跨平台)

Profiler → Memory 模块

Window → Analysis → Profiler → Memory。它给的是分类汇总,适合看趋势、找异常方向:

分类 含义 排查重点
Managed Heap 托管堆已用 / 预留 C# 对象、字符串、容器、泄漏
Graphics & Graphics Driver 引擎估算的纹理、RT、Shader、Mesh 与驱动开销 贴图问题的第一入口
Audio 音频系统估算占用 AudioClip 的 Load Type
Video 视频系统占用 VideoPlayer / 视频流
Other 未被上述分类统计的原生内存 需要 Memory Profiler 快照才能拆开
Profiler Profiler 自身的开销 真机采样时要扣掉

注意 Graphics & Graphics Driver估算值,只用来定位方向,不能当作精确的资产内存;精确值要用快照。

Memory Profiler 包(最重要的工具)

包名 com.unity.memoryprofiler。它才是能回答“哪个对象、占多少、被谁引用”的工具:

能力 用法
全量快照 拍一张,包含托管堆 + 原生内存 + 图形资源的对象图
按类型 / 大小排序 All Of Unity Objects 里按大小倒序,直接看到最贵的贴图和 RenderTexture
Textures 表 列出每张贴图的尺寸、TextureFormat、Mip、是否 Read/Write、实际字节数——验证“导入配置是否真的生效”的唯一可靠手段
引用链 这张贴图被哪个 AssetBundle / GameObject / Material 持有
快照 Diff 两张快照对比,看进场景前后新增和未释放的资源
Managed Heap 托管堆里的对象与类型分布,找增长源

用法要点:

  • 场景加载拍一张、稳定后拍一张、卸载后再拍一张,三张一组才能区分“常驻 / 峰值 / 泄漏”。
  • 快照本身很占内存,大项目可能几百 MB,真机上要留余量。
  • 如果 Textures 表里某张贴图的格式是 RGBA32,而配置写的是 ASTC,说明回退发生了(见 不支持的格式会怎样)。

关键 API

不开快照也能在代码里拿到分类数字:

using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Profiling;

public static class AssetMemoryReport
{
    /// <summary>按类型汇总已加载 Unity 对象的运行时内存(Editor / Development Build)。</summary>
    public static string Build()
    {
        var map = new Dictionary<Type, (int count, long bytes)>();
        var objects = Resources.FindObjectsOfTypeAll<UnityEngine.Object>();

        foreach (var obj in objects)
        {
            long size = Profiler.GetRuntimeMemorySizeLong(obj);
            if (size <= 0) continue;

            var t = obj.GetType();
            map.TryGetValue(t, out var cur);
            map[t] = (cur.count + 1, cur.bytes + size);
        }

        var sb = new StringBuilder("[AssetMemoryReport]\n");
        foreach (var kv in map)
            sb.AppendLine($"{kv.Key.Name,-24} count={kv.Value.count,-6} {kv.Value.bytes / 1024f / 1024f,8:F2} MB");

        return sb.ToString();
    }
}

Profiler.GetRuntimeMemorySizeLong 返回的是该对象持有的运行时内存:对贴图是压缩块数据大小,对 AudioClip 取决于 Load Type,对 Shader 是进入内存的变体数据。它不包含驱动 / GPU 侧的额外开销,所以只适合做“同类之间对比”,不要拿它当显存总量。

配套常用查询:

目标 API
设备是否支持某格式 SystemInfo.SupportsTextureFormat / SystemInfo.IsFormatSupported
贴图实际格式 Texture2D.format / Texture2D.graphicsFormat
Mip 数量 Texture2D.mipmapCount
音频加载方式 AudioClip.loadType / AudioClip.loadInBackground
手动触发回收 Resources.UnloadUnusedAssets

构建侧工具

工具 开源 用途
Build Report Inspector Unity-Technologies/BuildReportInspector 构建报告:各资源在包体里的实际大小与压缩后大小
AssetBundle Browser Unity-Technologies/AssetBundles-Browser 看 AB 的内容、依赖、冗余与大小
Addressables Analyze 内置 重复资源、未引用资源、Bundle 布局
Build Layout Report 内置(buildlayout.json Addressables 构建的 Bundle 与资源明细
Shader 变体剥离 内置 Graphics Settings 的 Shader Stripping、ShaderVariantCollectionIPreprocessShaders

厂商工具(真机 GPU / 系统口径)

平台 / 芯片 工具 能回答什么 备注
iOS / macOS Xcode Instruments(Allocations、VM Tracker、Metal System Trace、Metal Debugger) 原生内存、GPU 时间线、逐 pass / 逐 draw 的 GPU 资源 免费;Metal Debugger 是 iOS 上体验最接近 RenderDoc 的工具
iOS Xcode Memory Graph Debugger 原生对象引用链、循环引用泄漏 免费
Android Android Studio Profiler Java / 原生堆、CPU、GPU、网络 免费;对 Unity 原生内存的视角有限
Android adb shell dumpsys meminfo <pkg> PSS 明细,含 GL mtrack / Graphics 命令行最快;不同 Android 版本字段名有差异
Android Perfetto 系统级 trace:内存、调度、GPU 频率、内存压力 开源(google/perfetto),Android 官方主推
Android Android Performance Analyzer(APA) 系统级 CPU / GPU / 内存 / 功耗分析、GPU 计数器 Google 新推荐工具,前身是 AGI(google/agi,Apache-2.0,仍可下载)
高通 Adreno Snapdragon Profiler GPU 计数器、内存、逐 draw、带宽 免费,需高通账号
Arm(Mali / CPU) Arm Performance Studio(Streamline、Frame Advisor、Mali Offline Compiler 等) Mali GPU 计数器、CPU 采样、shader 离线静态分析 免费;老名字是 Arm Mobile Studio(2024.0 起改名)
通用逐帧 RenderDoc 抓帧、看每个 draw 的纹理 / RT / shader / PSO,并把贴图内容可视化 开源(baldurk/renderdoc),可 attach Android;Arm 也发行了针对 Mali 的定制版

要点:厂商工具回答的是**“GPU 和系统层面发生了什么”**,它们通常不会告诉你“这张 2048² 的图是 ASTC 还是被回退成了 RGBA32”。资产级的问题留给 Memory Profiler。

第三方线上平台(国内项目常用)

平台 定位 优势
UWA(GOT Online / GPM / 本地资源检测 / AssetBundle 检测) 真机性能与资源内存的持续监测 + 规则化检测 报告维度直接对齐 Unity 的分类(贴图 / 网格 / Shader / 音频 / 代码),有行业横向对比;Shader 变体数量这类问题有现成规则
PerfDog(腾讯 WeTest) 全平台性能采集 零侵入、设备覆盖广、可进 CI;只看系统口径,看不到单资源
WeTest 深度性能测试 手游性能与兼容性测试平台 结合真机机房,适合发版前批量跑机型

选型建议:日常定位用 Unity 自带工具,版本级回归用线上平台。 自带工具能下钻到单资源,线上平台的价值在于跨机型、跨版本的趋势与门禁

微信小游戏等平台用各自开发者工具的性能面板(如 wx.getPerformance()),内存口径与原生 App 不同,不要直接对比。

按资源类型怎么查

资源 第一步(定位) 第二步(确认根因)
贴图 Profiler Memory 看 Graphics 是否异常 Memory Profiler → Textures,核对 format / 尺寸 / Mip / Read-Write 是否等于配置;不等就是回退
RenderTexture Memory Profiler 按大小倒序看 RT 检查临时 RT 是否 Release();管线是否产生了多余的全屏 RT
Mesh Profiler Memory 看网格分类;Memory Profiler 按 Mesh 排序 是否开了 Read/Write、是否有重复网格
Shader Profiler Memory 的 Shader 计数与大小 Memory Profiler 看 Shader 对象;构建侧看变体数量与剥离结果
音效 Profiler → Audio 模块看播放数与内存 Memory Profiler 看 AudioClip;核对 Load Type(Decompress On Load / Compressed In Memory / Streaming)、Compression Format、Force To Mono、Preload
托管堆 Profiler Memory 的 Managed Heap 曲线 Memory Profiler 快照的 Managed Heap 表,按类型和大小找增长源

采样规范(否则数据不可信)

  • 必须真机 + Development Build:编辑器格式、驱动、内存管理都不是真机行为。
  • 固定条件:同一场景、同一相机路径、同一画质档、同一台设备,否则数字没有可比性。
  • 看峰值,不只看稳态:峰值往往出现在场景切换 / Loading / 大特效,OOM 也发生在那里。
  • 区分口径:任务管理器的 RSS、Android 的 PSS、Unity 的 System Used Memory 不是同一个数字,跨工具对比前先确认口径。
  • 快照要成组:单张快照只能看到“现在有什么”,看不到“谁涨的”。
  • 别一边挂 Profiler 一边测性能:Profiler 自身有开销,Profiler 分类的内存也要减掉。

工具层面的常见坑

现象 原因 处理
编辑器里贴图全是 RGBA32 编辑器平台不套用移动端压缩格式 用 Build Profiles 的 Asset Import Overrides 强制,或直接看真机快照
Profiler 的 Graphics 和任务管理器对不上 一个是引擎估算,一个是系统口径 用 Memory Profiler 快照 + 系统工具交叉验证
快照里贴图比配置大很多 格式回退,或 Read/Write 留了 CPU 副本 format 是否等于配置格式;关掉 Read/Write
找不到某个贴图是谁加载的 只看了对象列表,没看引用链 用 Memory Profiler 的引用链 / All Of Unity Objects 反查
Android 上 dumpsys meminfo 没有 Graphics 字段 系统版本 / GPU 驱动差异 改用 Perfetto,或直接看 GL mtrack
采样时游戏明显变卡 Profiler 采样本身有开销 分开测:一次拿数据,一次拿性能

大厂工程规范

规范的核心不是“写一份文档”,而是让错误的配置在提交时就被拦住

资源分级与预算表

先定义清楚,再谈执行。示例:

资源等级 尺寸上限 无 Alpha 格式 有 Alpha 格式 Mip 典型数量级
S 级:主角 / 关键 UI 2048 ASTC 4×4 ASTC 4×4 主角开,UI 关 十几张
A 级:角色 / 场景主要物件 2048 ASTC 6×6 ASTC 6×6 数百张
B 级:普通道具 / 特效 1024 ASTC 6×6 ASTC 6×6 上千张
C 级:远景 / 背景 / 装饰 512–1024 ASTC 8×8 ASTC 8×8 数千张
UI 图集 2048/图集 ASTC 6×6 几十个图集
法线 同所属角色 BC5 / ASTC 5×5 同角色数

配套的全场景贴图内存预算(必须进入提测 Checklist):

场景 低端机 中端机 高端机
主城贴图驻留 ≤ 180 MB ≤ 320 MB ≤ 500 MB
战斗贴图驻留 ≤ 250 MB ≤ 450 MB ≤ 700 MB
单张贴图上限 2048 2048 4096(仅特例)

导入强制:AssetPostprocessor

不要让美术手动配格式。 用导入管线统一收口,只在 importSettingsMissing(首次导入)或不符合规则时改写。

using UnityEditor;
using UnityEngine;

public class TextureImportRule : AssetPostprocessor
{
    private void OnPreprocessTexture()
    {
        var importer = (TextureImporter)assetImporter;
        bool isUi = assetPath.Contains("/UI/");
        bool isNormal = assetPath.Contains("_Normal");

        // 1) 通用安全默认值:任何贴图都不允许带 CPU 副本
        importer.isReadable = false;
        importer.crunchedCompression = false;
        importer.wrapMode = TextureWrapMode.Clamp;

        // 2) 法线:类型 + 线性空间 + 高保真格式
        if (isNormal)
        {
            importer.textureType = TextureImporterType.NormalMap;
            importer.sRGBTexture = false;
            importer.maxTextureSize = 2048;
            importer.mipmapEnabled = true;
            ApplyAndroid(importer, TextureImporterFormat.ASTC_5x5, 2048);
            ApplyIos(importer, TextureImporterFormat.ASTC_5x5, 2048);
            return;
        }

        // 3) UI:关 Mip、关 Streaming、按需限制尺寸
        if (isUi)
        {
            importer.textureType = TextureImporterType.Sprite;
            importer.sRGBTexture = true;
            importer.mipmapEnabled = false;
            importer.streamingMipmaps = false;
            importer.maxTextureSize = 1024;
            ApplyAndroid(importer, TextureImporterFormat.ASTC_6x6, 1024);
            ApplyIos(importer, TextureImporterFormat.ASTC_6x6, 1024);
            return;
        }

        // 4) 3D 反照率:开 Mip + Streaming
        importer.sRGBTexture = true;
        importer.mipmapEnabled = true;
        importer.streamingMipmaps = true;
        importer.maxTextureSize = 2048;
        ApplyAndroid(importer, TextureImporterFormat.ASTC_6x6, 2048);
        ApplyIos(importer, TextureImporterFormat.ASTC_6x6, 2048);
    }

    private static void ApplyAndroid(TextureImporter importer, TextureImporterFormat format, int maxSize)
    {
        importer.SetPlatformTextureSettings(new TextureImporterPlatformSettings
        {
            name = "Android",
            overridden = true,
            maxTextureSize = maxSize,
            format = format,
            textureCompression = TextureImporterCompression.Compressed,
            crunchedCompression = false,
            // 不支持 ETC2 的老设备兜底:宁可降分辨率,也不要 32 位全尺寸
            androidETC2FallbackOverride = AndroidETC2FallbackOverride.Quality16Bit
        });
    }

    private static void ApplyIos(TextureImporter importer, TextureImporterFormat format, int maxSize)
    {
        importer.SetPlatformTextureSettings(new TextureImporterPlatformSettings
        {
            name = "iPhone",
            overridden = true,
            maxTextureSize = maxSize,
            format = format,
            textureCompression = TextureImporterCompression.Compressed,
            crunchedCompression = false
        });
    }
}

资源扫描与 CI 门禁

规范必须能被机器检查,否则一定会腐化。扫描项:

检查项 判定 处理
Read/Write Enabled 为真 违规 自动关闭并提交
尺寸超过该目录等级上限 违规 阻断合并,要求改尺寸
平台格式未覆盖(overridden == false 违规 自动套规则
法线贴图 sRGBTexture == true 违规 自动关闭
非颜色贴图 sRGBTexture == true 违规 人工确认
尺寸不是 4 的倍数 警告 提示改为 2 次幂或 4 倍数
RGBA 贴图实际 Alpha 全 255 警告 建议改 RGB 格式,直接省一半
UI 贴图开了 Mip 警告 建议关闭
Crunch 用在非 C 级资源 警告 确认是否必要
同一资源在多处重复导入 违规 要求合并到图集/公共目录

编辑器侧扫描骨架:

using System.Collections.Generic;
using UnityEditor;
using UnityEngine;

public static class TextureAudit
{
    [MenuItem("Tools/Texture/Audit Selection And Report")]
    public static void Audit()
    {
        var violations = new List<string>();
        foreach (var guid in AssetDatabase.FindAssets("t:Texture2D", new[] { "Assets/Arts" }))
        {
            var path = AssetDatabase.GUIDToAssetPath(guid);
            var importer = AssetImporter.GetAtPath(path) as TextureImporter;
            if (importer == null) continue;

            if (importer.isReadable)
                violations.Add($"[Read/Write] {path}");

            var android = importer.GetPlatformTextureSettings("Android");
            if (!android.overridden)
                violations.Add($"[No Android Override] {path}");

            if (importer.textureType == TextureImporterType.NormalMap && importer.sRGBTexture)
                violations.Add($"[Normal sRGB] {path}");

            if (path.Contains("/UI/") && importer.mipmapEnabled)
                violations.Add($"[UI Mip On] {path}");
        }

        foreach (var v in violations) Debug.LogWarning(v);
        Debug.Log($"Texture audit done. Violations: {violations.Count}");
    }
}

同一套检查要在 CI 的 -batchmode -executeMethod 里跑,并把违规数作为门禁指标。只有进入流水线的规范才是规范。

Editor 侧内存估算

在导入前就能算出内存,避免等到真机才发现:

using UnityEngine;

public static class TextureMemoryEstimator
{
    /// <summary>按块压缩公式估算纹理内存(含 Mip 链)。</summary>
    public static long EstimateBytes(int width, int height,
        int blockWidth, int blockHeight, int blockBytes, bool mipmaps)
    {
        long total = 0;
        int w = width, h = height;

        while (true)
        {
            int blocksX = (w + blockWidth - 1) / blockWidth;
            int blocksY = (h + blockHeight - 1) / blockHeight;
            total += (long)blocksX * blocksY * blockBytes;

            if (!mipmaps || (w == 1 && h == 1)) break;
            w = Mathf.Max(1, w >> 1);
            h = Mathf.Max(1, h >> 1);
        }
        return total;
    }
}

// 例:2048² ASTC 6x6 含 Mip
// var bytes = TextureMemoryEstimator.EstimateBytes(2048, 2048, 6, 6, 16, true);
// bytes == 2497712  →  约 2.38 MiB

把这张表接进资源扫描报告,就能在美术提交时直接看到“这个目录的贴图内存从多少涨到多少”。

常见坑与排障

症状 可能原因 排查手段 处理
真机贴图内存远超预期 格式不被支持,回退成 RGBA32 并保留两份 Texture2D.format、Memory Profiler、SystemInfo.SupportsTextureFormat 换平台支持的格式,或走 AAB 多格式分发
内存只涨不降 Bundle 未 Unload;纹理引用未释放 Memory Profiler Diff、Addressables 事件 释放句柄、Resources.UnloadUnusedAssets
贴图内存是预期两倍 Read/Write Enabled 开着 资源扫描 关闭;运行时纹理用 Apply(true, true)
Streaming Mipmap 不生效 纹理可读,或用了 Crunch 导入设置 关 Read/Write,关 Crunch
编辑器正常,真机偏亮/偏暗 sRGB 勾错 逐平台对比截图 颜色开、数据关
法线光照发花 用 BC1/BC3 存法线,或格式太粗 近景高光、金属对比 用 BC5 / ASTC 5×5 以上,确认 sRGB 关闭
Mask 行为在低端机不一样 压缩误差跨过 shader 阈值 分通道可视化 提高该图格式档位,或改 shader 容差逻辑
半透明边缘变硬/锯齿 用了 1bit Alpha(ETC1/ETC2 Punchthrough/BC1) 放大观察边缘 换 ASTC 或 8bpp 方案
Android 装包后引擎初始化失败 自定义 Gradle 模板缺 texture { enableSplit = true } logcat + 检查 AAB 切片 更新 launcherTemplate.gradle
AAB 里没有 ASTC 切片 只打 APK,或列表没加 ASTC,或 Asset Import Overrides 是 Force Uncompressed bundletool 解 AAB 补配置后重新出包
远端 Bundle 在部分设备花屏/异常 不同格式资源混在同一 URL,缓存串了 CDN 缓存 key、Bundle 名 把格式编进 Profile / URL / 缓存 key
下载体积超标 贴图尺寸过大、未走 Crunch、重复资源 Build Layout Report、Addressables Analyze 降尺寸优先,其次 Crunch,再查重复
导入/构建时间过长 全局开了 Crunch 或用了高质量 ASTC 编码 构建日志 只对必要资源开 Crunch
小图很多,内存却不低 每张小图都单独压缩,块对齐浪费大 贴图数量与尺寸分布统计 打进图集,或改小格式(R8/RG16)

验收清单与交付物

发版前必须回答的问题

  • 每个平台的默认纹理压缩格式是什么?有没有逐纹理的 Platform Override 打乱全局策略?
  • Android 是 AAB 还是 APK?如果是 AAB,Texture Compression Formats 是否配好、切片是否真的包含目标格式?
  • 走 AAB 的项目,Gradle 模板是否包含 texture { enableSplit = true }
  • 有没有任何资源可能触发运行时格式回退?回退后的内存峰值算过吗?
  • 全项目 Read/Write Enabled 是否已清零?运行时创建的纹理是否都 Apply(true, true)
  • UI 贴图是否关掉 Mip?3D 贴图是否开了 Mip 和 Streaming?
  • 法线和 Mask 是否走了正确格式、正确色彩空间?
  • 远端 Addressables 是否做了格式分离(URL / 缓存 key / catalog)?
  • 低端机上贴图驻留峰值是多少?离预算还差多少?
  • 尺寸分布是否合理?有没有 4K 图被当 300 像素用?
  • 是否用 Memory Profiler 快照验证过贴图的实际格式与字节数,而不是只看导入设置?(见 工具链
  • 音效的 Load Type / Compression Format 是否按使用场景区分?长音频是否走了 Streaming?

要交付的东西

交付物 内容
贴图资源规范 分级表、尺寸上限、格式矩阵、Mip/sRGB/Read-Write 规则
导入管线脚本 AssetPostprocessor 自动套规则,覆盖首导和异常配置
资源扫描器 + CI 门禁 上述检查项,违规数进流水线指标
贴图内存估算工具 块压缩公式实现,输出按目录/等级的估算报告
多格式分包方案 Android AAB targeting 配置 + 远端 Bundle 的格式分离策略
真机验证清单 iOS/Android 的 ASTC、ETC2、回退三条路径分别验证
性能 / 内存工具链清单 每个平台的标准采样流程、工具组合、快照命名与留档规范
排障手册 上面的症状对照表,接入到线上问题处理流程

一句话总结

贴图内存 = 分辨率 × bpp × Mip 系数 × 副本数。

  • 分辨率是最大杠杆(降一级省 3/4),其次才是格式(ASTC 6×6 相对 RGBA32 省 8/9);
  • 格式选择的本质是在“设备支持”的前提下选够用的 bpp,没有全局最优解;
  • 移动端的正确姿势是 ETC2 兜底 + ASTC 优化 + AAB 分发,而不是赌所有设备都支持 ASTC;
  • 最贵的错误不是画质差一点,而是格式不支持导致的 runtime 回退——它会让内存九倍增长,且同时存在两份数据。
  • Crunch 省的是磁盘,不是显存2 的次方也不再是硬要求——POT 影响的是兼容性与块对齐浪费,不是内存公式本身;
  • 所有结论都要在真机 + 实际下发的包上用 Memory Profiler 快照验证,编辑器里的数字不算数。

相关阅读:Unity 开发高级/资深 08:性能、内存与包体Unity 开发高级/资深 07:渲染、Shader 与 TA 协作

评论