4、资源压缩策略:纹理压缩格式(ETC2/ASTC)、量化与抖动、调色板技术

资源压缩,说白了就是跟显存和带宽较劲。你想想看,一张 2048×2048 的 RGBA 纹理,不压缩的话要占 16MB 内存。如果游戏里有 100 张这样的纹理,那就是 1.6GB。嵌入式设备哪扛得住?

我个人习惯把资源压缩分成三个层次:纹理格式压缩、像素深度量化、调色板索引。这三招用好了,显存占用能砍掉 60%~80%,而且画质损失肉眼几乎看不出来。

核心原则:压缩不是无脑降质量,而是在「带宽-画质-兼容性」三角中找到平衡点。不同平台、不同纹理类型,策略完全不同。

资源压缩策略全景图 原始纹理资源 纹理格式压缩 量化与抖动 调色板技术 ETC2 ASTC 均匀量化 抖动算法 8位索引 4位索引 选择策略的关键因素 • 目标平台:ETC2 是 OpenGL ES 3.0 标配,ASTC 是移动端新宠 • 纹理类型:UI 元素适合调色板,照片类适合 ASTC • 质量要求:UI 图标用 4 位索引几乎无损,背景图建议 ASTC 6x6 • 运行时开销:调色板解压最快,ASTC 解压稍慢但可接受

4.1 纹理压缩格式:ETC2 与 ASTC

纹理压缩跟图片压缩(比如 PNG、JPEG)完全是两码事。纹理压缩后的数据要能被 GPU 直接采样,不需要解压到内存里再上传。所以压缩格式必须支持随机访问——也就是说,GPU 可以只解压它需要的那个像素块。

4.1.1 ETC2:OpenGL ES 3.0 的标配

ETC2 是 ETC1 的升级版。ETC1 只能压缩 RGB,不支持 Alpha 通道。ETC2 补上了这个短板,支持 RGBA,而且压缩质量更好。

我记得第一次在项目里用 ETC2 时,有个坑特别容易踩——ETC2 要求纹理尺寸必须是 4 的倍数。因为它的压缩单元是 4×4 像素块。如果纹理是 1026×1026,GPU 会直接罢工。

避坑指南:我曾经在某个项目里,美术同学给了一张 1022×1022 的 UI 背景图。用 ETC2 压缩后,在测试机上花屏了半小时。后来排查才发现是尺寸没对齐。解决方案:要么补齐到 1024×1024,要么用 ASTC(它支持任意尺寸)。

ETC2 的压缩比是固定的 6:1(RGB)或 4:1(RGBA)。每个 4×4 像素块压缩成 64 位或 128 位数据。解码逻辑在硬件里写死了,所以解压速度极快,几乎不耗 CPU。

格式 颜色通道 压缩比 尺寸要求 典型场景
ETC1 RGB 6:1 4 的倍数 旧设备兼容
ETC2 RGB RGB 6:1 4 的倍数 Android 主流
ETC2 RGBA RGBA 4:1 4 的倍数 UI 元素
ETC2 sRGB sRGB 6:1 4 的倍数 颜色校正

4.1.2 ASTC:移动端的万能格式

ASTC 是 ARM 推出的纹理压缩格式,后来被 Khronos 收编成了 OpenGL ES 和 Vulkan 的扩展。它的最大优势是灵活——你可以选择不同的压缩块大小,从 4×4 到 12×12,压缩比从 2:1 到 16:1 随便调。

我个人习惯这样选:

  • UI 图标、按钮:用 ASTC 4×4,压缩比 2:1,画质几乎无损
  • 场景贴图、角色皮肤:用 ASTC 6×6,压缩比 4.5:1,画质可接受
  • 背景图、天空盒:用 ASTC 8×8 甚至 10×10,压缩比 8:1 以上

注意:ASTC 虽然好,但不是所有设备都支持。老款手机(比如 2014 年以前的)可能不支持。我建议在代码里做运行时检测:如果支持 ASTC 就用,不支持就 fallback 到 ETC2 或 RGBA 原始数据。

ASTC 的编码质量跟压缩时间成正比。离线压缩时,我一般用 astcenc 工具,质量等级选 -medium 或 -thorough。运行时解压是硬件完成的,速度跟 ETC2 差不多。

# 使用 astcenc 压缩纹理
# 输入:RGBA PNG,输出:ASTC 6x6
astcenc -cl source.png output.astc 6x6 -medium

# 解压回 PNG 做质量对比
astcenc -dl output.astc decompressed.png

4.2 量化与抖动:用精度换空间

量化说白了就是减少每个像素的位数。比如从 8 位每通道降到 5 位,内存直接省掉 37.5%。但直接截断低位会带来色带——平滑渐变的地方出现一圈一圈的条纹。

抖动的思路是:用噪声把量化误差打散,让肉眼觉得颜色过渡是连续的。Floyd-Steinberg 抖动算法是经典方案,它把当前像素的量化误差按比例扩散到相邻像素。

我的经验:在嵌入式设备上,UI 元素的渐变背景用 16 位(RGB565)加抖动,效果跟 32 位几乎一样。但如果是照片类纹理,16 位就不够看了,至少得 24 位。

量化策略对比:

格式 位数 每像素大小 适用场景 抖动效果
RGB565 16 2 字节 UI 元素、图标 明显改善色带
RGBA4444 16 2 字节 半透明 UI Alpha 通道抖动
RGB888 24 3 字节 照片、场景 不需要
RGBA8888 32 4 字节 高质量纹理 不需要

抖动算法的实现其实不复杂。我贴一段 Floyd-Steinberg 抖动的伪代码,你可以在预处理阶段用:

// Floyd-Steinberg 抖动(伪代码)
for each pixel (x, y) in image:
    old_pixel = image[x][y]
    new_pixel = quantize(old_pixel, bits_per_channel)
    image[x][y] = new_pixel
    
    error = old_pixel - new_pixel
    
    // 将误差扩散到相邻像素
    image[x+1][y]   += error * 7/16
    image[x-1][y+1] += error * 3/16
    image[x][y+1]   += error * 5/16
    image[x+1][y+1] += error * 1/16

小技巧:如果你用 RGBA4444 做 UI 纹理,记得对 Alpha 通道也做抖动。不然半透明边缘会出现锯齿状的色块。我曾经在一个启动画面里踩过这个坑,修复后效果立竿见影。

4.3 调色板技术:老技术新用

调色板技术其实很古老——从 8 位色时代的 GIF 和 PCX 文件就开始用了。但在嵌入式图形里,它依然是个利器。

原理很简单:把纹理里用到的颜色提取出来,做成一个颜色表(调色板),每个像素只存一个索引值。比如 256 色调色板,每个像素只占 1 字节,而不是 3 字节(RGB888)或 4 字节(RGBA8888)。

我一般在以下场景用调色板:

  • UI 图标、按钮:颜色数少(通常不超过 64 色),用 6 位索引就够了
  • 字体纹理:单色或双色,用 1 位或 2 位索引
  • 小尺寸贴图:比如 32×32 的粒子纹理

调色板的压缩比:

调色板大小 索引位数 每像素大小 压缩比(相对 RGB888)
16 色 4 位 0.5 字节 6:1
64 色 6 位 0.75 字节 4:1
256 色 8 位 1 字节 3:1

注意:调色板纹理在 GPU 上需要特殊的格式支持。OpenGL ES 2.0 以上可以用 GL_PALETTE8_RGB8_OES 等扩展。如果硬件不支持,你得在 CPU 端解压成 RGBA 再上传——那就失去压缩的意义了。

调色板的生成算法也很关键。我推荐用中位切分法(Median Cut),它能把颜色空间切分成视觉上均匀的区间。简单实现:

// 中位切分法生成调色板(伪代码)
function generatePalette(image, colorCount):
    // 1. 统计所有像素颜色
    colors = extractAllColors(image)
    
    // 2. 递归切分颜色空间
    boxes = [colorBox(colors)]
    while len(boxes) < colorCount:
        // 找到包含颜色最多的 box
        box = findLargestBox(boxes)
        // 沿最长轴切分
        splitBox(box)
        boxes.remove(box)
        boxes.add(box.left)
        boxes.add(box.right)
    
    // 3. 每个 box 取平均色作为调色板条目
    palette = []
    for box in boxes:
        palette.append(box.averageColor())
    
    return palette

实战建议:调色板技术最适合 UI 资源。我做过一个项目,整套 UI 图标(约 200 个)用 64 色调色板 + 4 位索引,总大小从 12MB 降到了 1.8MB。而且因为索引值小,纹理上传速度也快了不少。

最后说一句:这三种策略不是互斥的。你可以对 UI 纹理用调色板,对场景贴图用 ASTC,对渐变背景用 RGB565 加抖动。关键是根据纹理类型和平台特性灵活组合。嗯,这就是我这些年积累下来的经验。