纹理,那个吃掉你显存的大胃王
上一篇我们把顶点砍了一半,模型小了点,但没瘦成一道闪电——因为真正的体积大户还在那儿:纹理。一个 PBR 模型,纹理通常占 80% 以上体积,而且这部分在显存里膨胀得最凶。
这一篇专治纹理的「显存大胃王」问题。讲三件事:为什么 PNG/JPG 在 GPU 眼里有原罪;GPU 自己的纹理格式长什么样、为什么不能直接用;以及 Basis Universal + KTX2 这套组合是怎么把三者打通的。
先复习一遍:为什么 JPG 让显存爆炸
上一篇给过一个公式:
显存占用 = 宽度 * 高度 * 4字节(RGBA) * 1.333(含mipmap)
一张 4096×4096 的纹理,无论它在磁盘上是 1.5MB 的 JPG 还是 8MB 的 PNG,进了显存都是 约 87MB。原因只有一个:GPU 不认识 JPG/PNG。
GPU 的纹理采样单元(texture sampler)只懂一件事——给定一个 UV 坐标,从一个固定大小的像素块里读出颜色。它要求纹理在显存里是「铺平的原始像素」。所以浏览器在把 JPG 上传到 GPU 之前,必须先用 CPU 把它完全解压成 RGBA 像素,再整块塞进显存。
这个过程有三重麻烦:
- 显存爆炸:解压后的原始像素占用巨大,87MB 不是夸张,是公式算出来的。
- 上传阻塞:大块像素从 CPU 内存搬到 GPU 显存是个慢操作,会卡住首帧渲染。
- CPU 解压开销:大图解压本身就费时,移动端尤其明显。
延续上一篇「压缩海绵」的比喻:PNG/JPG 就是捏扁了的海绵,方便运输;一到 GPU 上,海绵吸水膨胀成原样。下载是快了,显存一点没省。
GPU 自己的纹理格式:天生就在显存里压着
既然 GPU 不接受压缩好的 PNG,那能不能让纹理在显存里也保持压缩状态?GPU 采样时实时解码单个像素块,几乎无开销。
这就是 GPU 原生纹理格式(GPU-native texture format)做的事。代表家族:
| 格式家族 | 全称 | 主要平台 | 特点 |
|---|---|---|---|
| BC1-7 | Block Compression | 桌面(PC、Mac) | 老牌,每代 4×4 像素块压缩 |
| ETC1/2 | Ericsson Texture Compression | 移动(Android/iOS 老设备) | 移动端老标准 |
| ASTC | Adaptive Scalable Texture Compression | 移动/VR(新设备) | 灵活,质量最好,逐块可调 |
| PVRTC | PowerVR | 老 iOS | 已逐步被 ASTC 取代 |
这些格式的共同点:纹理按 4×4 像素的小块(block)压缩存储,GPU 在采样时按需解码这一小块,解出来的不是单个像素而是一块。好处是显存占用直接按固定比例缩水,无论内容是什么。
对比一下:
| PNG/JPG(传统) | GPU 原生格式 | |
|---|---|---|
| 磁盘大小 | 小(JPG 尤其小) | 中(按块压缩,固定码率) |
| 显存占用 | 大(解压回原始像素) | 小(块状压缩,常驻) |
| 上传到 GPU | 慢(CPU 解压 + 大块传输) | 快(直接搬,无需解压) |
| 采样速度 | 快(已是原始像素) | 快(硬件实时解码) |
看起来 GPU 格式是完美方案。那为什么不能直接用它们?
问题来了:不同设备认不同格式
这正是 GPU 纹理格式最大的坑——碎片化。
- 桌面 PC 认 BC1-7,不认 ASTC
- 安卓手机认 ETC2/ASTC,多数不认 BC
- iOS(A7 之后)认 ASTC,老机型认 PVRTC
- WebGPU/WebGL 走的还是设备背后的同一套硬件能力
一张纹理,如果你想让它「在所有设备上都以 GPU 原生格式存在」,就得为每个平台分别准备一份。一个产品要发桌面 + 安卓 + iOS,同一张贴图要做 BC + ETC2/ASTC 三套版本。包体翻三倍,工程量翻三倍。
更糟的是,Web 端你根本不知道用户拿什么设备打开页面。预生成所有格式不现实,运行时检测又来不及。
Basis Universal:一次编码,到处转码
Basis Universal(简称 Basis)就是为解决这个碎片化而生的。它的思路一句话:
先把纹理编码成一种「中间格式」,运行时再根据当前设备的 GPU 能力,转码(transcode)成对应的原生格式。
转码流程(示意图):
源纹理(PNG/JPG)
│ 一次性离线编码(慢,只做一次)
▼
Basis 中间格式(ETC1S 或 UASTC)
│ 打包进 KTX2 容器
▼
发布到 Web ──┬── 桌面 GPU ──→ 运行时转码 → BC1/3/7
├── Android ───→ 运行时转码 → ETC2
└── iOS/VR ────→ 运行时转码 → ASTC
关键点:
- 离线编码只做一次,得到一个紧凑的中间表示
- 运行时转码极快(纯计算、几毫秒级),而且转的是块格式,不需要逐像素解压
- 转码后送进显存的就是真正的 GPU 原生格式,显存占用按块压缩算,和 GPU 原生格式一致
Basis 提供两种中间编码模式,下一篇会展开讲,这里先记住名字:
- ETC1S:压缩率极高,适合漫反射/albedo 等颜色贴图
- UASTC:质量更高,适合法线等对精度敏感的贴图
KTX2:装 GPU 纹理的标准容器
到这里还有一个工程问题:编码后的 Basis 数据放哪、怎么标记、怎么和 glTF 关联?答案是 KTX2。
KTX2(Khronos Texture 2)不是又一种图片格式,而是一个容器格式——就像 .zip 不关心里面装的是文档还是图片,KTX2 只负责把 GPU 纹理数据(包括 Basis 编码的)按标准结构打包,并附上元信息(格式、mipmap 层级、色彩空间等)。
在 glTF 里,KTX2 通过扩展 KHR_texture_basisu 接入:纹理不再是 PNG 文件,而是一个 KTX2 文件,里面是 Basis 编码。加载时引擎检测设备能力,转码成对应的 BC/ETC/ASTC。
三者关系理一下,别混了:
| 名字 | 角色 | 类比 |
|---|---|---|
| Basis Universal | 编码方案(怎么把纹理压成中间格式) | 一种「压缩算法」 |
| KTX2 | 容器格式(怎么把编码后的数据打包) | 一个「包装盒」 |
| KHR_texture_basisu | glTF 扩展(告诉引擎这是 Basis 纹理) | 一个「标签」 |
一个 KTX2 文件,内部可以是 Basis 编码(跨平台),也可以是某种原生格式(比如直接装 BC7)。Web 上 99% 的情况是装 Basis,因为我们要的就是「一次编码、到处转码」。
VRAM 实例:4096 纹理的对比
把前面的公式和 GPU 格式叠在一起,看一张 4096×4096 RGBA 纹理在不同方案下的真实占用:
| 方案 | 磁盘大小 | 显存占用(含 mipmap) | 上传速度 | 跨平台 |
|---|---|---|---|---|
| PNG | ~8MB | ~87MB | 慢(需解压) | ✅ |
| JPG | ~1.5MB | ~87MB | 慢(需解压) | ✅ |
| WebP | ~2MB | ~87MB | 慢(需解压) | ✅ |
| KTX2 (ETC1S) | ~2-3MB | ~11-14MB | 快 | ✅(转码) |
| KTX2 (UASTC) | ~6-8MB | ~22MB | 快 | ✅(转码) |
显存数字怎么来的:GPU 块压缩通常按 4bpp(每像素 4 位)或 8bpp 算。4096×4096 在 4bpp 下约 8MB,含 mipmap 再乘 1.333 ≈ 11MB。UASTC 多数转码到 8bpp,所以约 22MB。
重点不是某一行的精确数字,而是这两条:
- 传统格式(PNG/JPG/WebP)显存占用几乎一样——都是解压后的原始像素,87MB。磁盘再小,显存不省。
- KTX2 显存直接降到 1/4 到 1/8,且磁盘大小也并不吃亏。
这也是为什么 VR、移动端 Web 几乎必上 KTX2——87MB 一张贴图在 2GB 显存的手机上能放几张?而 11MB 能放 7 张。
平台支持矩阵:哪些 GPU 认哪些格式
虽然 Basis 帮我们屏蔽了细节,但了解底层映射有助于排障。这是当前主流设备对原生格式的支持情况:
| 平台 / 设备 | BC1-7 | ETC2 | ASTC | PVRTC |
|---|---|---|---|---|
| 桌面 PC(D3D11/12、Vulkan、WebGPU) | ✅ | ❌ | 部分(新 GPU) | ❌ |
| macOS(Metal) | ✅(新机型) | ❌ | ✅ | ❌ |
| Android(主流) | ❌ | ✅ | ✅ | ❌ |
| iOS(A8+) | ❌ | ✅ | ✅ | ✅(老机型) |
| WebGL 2 | 取决于扩展 | ✅ | 部分 | ❌ |
| WebGPU | ✅(桌面) | ✅ | ✅(视设备) | ❌ |
Basis 在运行时会探测这些能力,把同一份中间编码转成最合适的那一个。这也是为什么 Basis 这一层在 Web 上几乎不可替代——你没法在发布前预知用户设备。
上传流程对比:传统 vs GPU 格式
最后用一张流程图把差别固化下来。
传统 PNG/JPG:
PNG 文件 ──下载──> CPU 内存 ──CPU 解压(慢)──> RGBA 像素块 ──上传(大,慢)──> 显存(87MB)
KTX2 + Basis:
KTX2 文件 ──下载──> CPU 内存 ──运行时转码(快)──> GPU 块格式 ──上传(小,快)──> 显存(11MB)
后者少了「CPU 逐像素解压」这一大块,上传的数据量也小一个数量级。首帧渲染快、显存省,是这套方案的核心价值。
下一步
道理讲完了,下一篇动手。我们用 toktx、gltf-transform 把纹理实际压成 KTX2,在 Three.js / Babylon.js 里加载出来,再聊聊 ETC1S 和 UASTC 怎么选、压缩参数怎么调。