客户端项目的内存和包体问题,最后往往都会落回到同一类资源上:贴图。很多团队对它的认知停留在“导入时选个 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(高 / 块高) × 每块字节数
这一点非常关键,它带来两个工程后果:
- 尺寸必须尽量对齐块宽高。2048 对 ASTC 6×6 来说要
ceil(2048/6) = 342个块,边缘块有大量无效像素被一起编码。尺寸越是对齐得差,浪费越大。 - 小图极其不划算。一张 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 有)见 各图形 API 的强制性 与 设备与 GPU 硬件。
- 想按资源类型选到具体块尺寸,见 按资源角色的格式与尺寸建议。
块压缩原理
固定尺寸块 + 定点解释
所有主流 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_6x6 与 ASTC_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_ldr,etc2 则要求 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 加载一张压缩格式不被设备支持的纹理时,它会把纹理解压成该平台的默认未压缩格式,并把未压缩副本和原始压缩纹理一起保存在内存中。
这意味着一次错误的格式配置,代价是:
- 内存成倍增长。ASTC 6×6(3.56bpp)回退到 RGBA32(32bpp)是 约 9 倍。2048² 从 2.38 MiB 变成 21.33 MiB。
- 加载时多出 CPU 解压开销,表现是加载卡顿、切场景变慢。
- 带宽压力暴涨,低端机直接 OOM 或掉帧。
- 两份数据同时存在,峰值内存比稳态更糟。
所以:运行时回退只能当最后的安全网,绝不能当正常发版路径。
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 | ToNearest 或 None。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、ShaderVariantCollection、IPreprocessShaders |
厂商工具(真机 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 协作。
评论