移动端项目做性能专项时,我几乎每次都会在RenderDoc里盯着纹理带宽那几栏待上半天。采样的纹理数量少说几十张,每张如果还是RGBA32直出,带宽压力说出来都是泪。ASTC(Adaptive Scalable Texture Compression)在这时候几乎是必然答案——硬件直接解压、压缩率可调、画质损失能做到非常低。ARM官方开源的那个astcenc编码器,是我见过少数“既能在命令行里当工具用、又值得把源码翻出来从头读到尾”的项目。这篇博文我以一次完整的源码审计视角来写,先讲清楚它内部长什么样,再谈怎么在真实图形项目里落地。
1. 为什么一个纹理压缩编码器值得做源码级审计
1.1 移动端带宽焦虑:从“显存不够”到“带宽不够”
PC平台做纹理压缩,很多人第一反应是省显存。但在移动端,问题更尖锐。移动GPU和CPU共享物理内存,带宽是真正的稀缺资源。你去查Mali、Adreno的架构文档就会发现,纹理单元每时钟周期能读取的字节数是有限制的。一个复杂的PBR场景,GBuffer要采样albedo、normal、roughness、metalness,再加上阴影贴图和光照探针,随便一帧就是几百次纹理fetch。
我做过一个简单的估算:一张2048x2048的RGBA8纹理,单次全屏采样大概要吃到32MB的带宽(这里只是采样一遍的带宽开销)。用ASTC 8x8之后,同样这张图降到2MB左右,带宽成本直接砍到十六分之一。移动端高分辨率渲染下,这个差距就是能跑60帧和掉到30帧的差距。所以纹理压缩在移动端不是优化项,是基础项。ASTC的优势在于它能覆盖从高画质到大压缩率的完整光谱——4x4块尺寸下每texel平均8bit,12x12块尺寸下每texel平均不到1bit,中间还有5x5、6x6、8x8、10x10这些档位可以按需求选。
1.2 ASTC为什么能成为移动端事实标准
提到纹理压缩格式,老玩家会先想起DXT/BC系列、ETC系列。BC系列是桌面端老将,但移动端硬件大多不支持硬件解压。ETC2是OpenGL ES 3.0的强制要求,但ETC2不支持高质量RGBA——它那个alpha通道其实是RGB加独立alpha的组合,对法线图这种两个通道都很重要的图就不太友好。
ASTC最初来自ARM,后来被Vulkan、OpenGL ES 3.1、以及主流移动GPU广泛支持。它最聪明的地方是采用固定128bit压缩块,但块内texel数量可变,这让它能在画质和压缩率之间自由伸缩。更重要的是,它天生支持RGBA、sRGB、HDR甚至3D纹理,一个格式打通所有场景。到2024年之后,几乎所有中高端移动设备都对ASTC有硬件解码支持,WebGL2和较新的桌面平台也把它纳入标准。所以“做移动端的,纹理格式就选ASTC”已经不是什么前卫选择,而是现实。正因为它如此重要,它的官方编码器才值得做一次源码级审计——你要知道它每个参数背后在调什么,才能在项目里真正用对它。
1.3 审计目标:我看的是哪条代码路径
我在GitHub上拉了ARM-software/astc-encoder的master分支,编译环境是Ubuntu 22.04,编译器gcc 12,开启了NEON指令集(在ARM机器上)。审计过程不是泛泛过一遍,而是带着几个具体问题:编码最耗时的地方在哪?为什么有些纹理压缩得特别慢?质量参数到底影响了什么?多线程调度是怎么划分任务的?这些问题都直接关系到项目集成时的性能预期和参数选择。
2. astcenc仓库架构:文件布局、构建系统与三个核心模块
2.1 仓库全景:没有第三方依赖,这是集成友好度天花板
astcenc的目录结构非常干净,核心代码在Source/目录下,没有第三方库依赖。CMakeLists.txt加起来也就是几页的配置量,这在图形算法项目里算很克制的了。整个项目编译出来有两个产物:一个是命令行工具astcenc,另一个是静态库astcenc-standalone(具体库名取决于你的构建配置)。如果你的项目想内置ASTC编码能力,直接把Source/里相关文件加进工程就能编,不需要处理外部依赖链。
Source/ ├── astcenc_entry.cpp # 主入口、API实现 ├── astcenc_compress_symbolic.cpp # 压缩主流程 ├── astcenc_decompress_symbolic.cpp# 解压流程 ├── astcenc_find_best_partitionings.cpp # 分区搜索算法 ├── astcenc_partition_tables.cpp # 分区模式数据表 ├── astcenc_quantization.cpp # 端点和权重量化 ├── astcenc_integer_sequences.cpp # 整数序列编码(bitstream写入) ├── astcenc_ideal_endpoints_and_weights.cpp # 端点和权重优化 ├── astcenc_color_quantize.cpp # 颜色量化 ├── astcenc_pick_best_endpoint_format.cpp # 端点格式选择 ├── astcenc_platform.cpp/.h # 平台抽象、线程池 ├── astcenc_vecmathlib_sse_*.h # SSE/AVX向量库 ├── astcenc_vecmathlib_neon_*.h # NEON向量库 └── astcenc_mathlib.cpp # 数学基础库文件名基本就是内容的注释,代码风格很清晰,读起来不太费劲。构建上,官方推荐CMake,常规两步走:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DASTCENC_ISA_NEON=ON cmake --build build -j这里有个关键点:如果你在ARM设备上编译,一定要显式打开ASTCENC_ISA_NEON;在x86上默认会启用SSE/AVX,但ARM上不主动开的话会退回C++标量实现,性能能掉好几倍。这个我在后面第4章展开讲。
2.2 三个核心模块:从API入口到bitstream落盘
整个编码器可以拆成三个层次来看:API与配置层、算法核心层、平台抽象层。
API与配置层主要是astcenc_entry.cpp,对外暴露astcenc_compress_image、astcenc_decompress_image这一组C接口。配置结构体astcenc_config管理压缩参数,包括块尺寸、质量档位、颜色空间、线程数等。这一层做的事情很纯粹:校验参数、准备输入输出、拉起编码任务。多数图形项目即使只把这套C API接进来,也能满足日常离线压缩需求。
算法核心层负责真正的编码,是源码审计的重头戏。它以compress_image为入口,把每个texture block(比如6x6个texel)当作独立编码单元,依次经过分区搜索、端点计算、量化、权重搜索、bitstream打包。最终把每块压成128bit的physical_compressed_block。
平台抽象层是最容易被人忽略但很精彩的部分。astcenc_platform.cpp实现了线程池、定时器、内存分配辅助函数。astcenc_vecmathlib_neon_*.h提供了跨平台的SIMD向量类型封装,在NEON上对应float32x4_t运算,在SSE上对应__m128。这层隔离了算法层对具体指令集的依赖,让同一套编码逻辑可以跑在不同架构上。
2.3 关键数据结构:理解三层缓冲区设计
读这种算法型项目,数据结构比函数更值得先看。astcenc里有三个核心数据结构,我建议按这个顺序理解:
image_block是输入图像的最小工作单元,一个block内最多包含12x12个texel(对应最大块尺寸),每个texel存RGBA的float值。注意这里用的是float而不是整数,因为后续颜色端点计算需要在高精度空间做,整数会累积误差。
symbolic_compressed_block是压缩过程中的“符号表示”,它不直接对应bitstream,而是包含了解码器重建所需的全部抽象信息:选中的分区模式、颜色端点模式、端点的量化值、权重量化值等。为什么需要这一层?因为bitstream的最终打包格式非常紧凑,每一步字段的排列方式和位数是固定的,搜索算法在调整权重时没必要反复把128bit拆开重组,在symbolic空间操作要快得多。
physical_compressed_block是最终落盘的128bit块,它就是对symbolic_compressed_block做字段排列后的结果。理解这三个结构的递进关系之后,再去看编码流程就不会被绕晕。
3. 编码管线拆解:从高分辨率图像到128bit块的全过程
3.1 分块与预处理:为什么4x4和12x12的差别不只是压缩率
ASTC编码的基本单位是block。你在命令行里写的6x6指的是block内texel尺寸,而不是最终的压缩块字节数——无论哪种尺寸,压缩后都是128bit。所以块尺寸越大,单个texel摊到的bit数越少,压缩率越高,但质量会随之下降。
astcenc的预处理阶段干了几件事:先检查图像尺寸是否被块尺寸整除,不能整除的部分用边缘像素复制填充;然后把RGB颜色转换到内部工作空间,有时会做颜色空间的线性化处理,这是为了后续寻找颜色端点和权重时减少误差;如果是带alpha的图,还需要决定走RGB还是RGBA的编码路径。这些预处理逻辑分散在astcenc_entry.cpp和astcenc_mathlib.cpp里,看起来不起眼,但对最终画质影响很大。
选块尺寸是多目标权衡。我做项目时的经验:UI界面用4x4(高画质需求),普通漫反射贴图用6x6,法线贴图我一般建议6x6但开-dbl的dual-plane模式(后面细说),遮罩/流场等低频图用8x8。4x4和8x8之间的视觉差距在高对比纹理上非常明显,8x8的平滑区域会开始出现块状瑕疵,具体临界点取决于纹理内容。
3.2 分区(Partition)搜索:把一块切成几个颜色区域的学问
ASTC最有技术含量的设计之一,是允许把一个block划分为最多4个独立分区,每个分区有自己的一组颜色端点和独立权重。这样同一个6x6的block,如果同时包含天空和云朵,编码器可以分开处理,避免用一组颜色硬凑两种差异极大的颜色。
分区形状不是随意定义的。ASTC规范内置了一张预计算的分区模式表,每种模式和分区数对应一组texel归属标记。astcenc在astcenc_find_best_partitionings.cpp里实现了分区选择:先用一个快速启发式算法估算每个分区的理想端点,再结合图像的局部颜色分布选出若干候选模式,最后对这组候选做完整编码并挑误差最小的。
真正值得关注的是percentile_tables,这是astcenc为加速搜索预先生成的统计表,记录各种颜色分布下哪些分区模式更可能胜出。搜索时先用百分位截断排除掉大量低概率模式,极大降低计算量。这个设计思路直接解释了“为什么同一张图用-thorough比-medium要慢那么多”——高质量档位会保留更多候选模式做完整评估,计算量成倍上升。
3.3 端点优化与量化:颜色精度如何逐轮收敛
每个分区内部,ASTC需要用两个颜色端点来表示一个颜色渐变。编码器的任务是找到最佳端点,让所有texel的颜色能被端点插值到。astcenc先把每个分区内的texel做PCA或者均值方向分析,求出一个理想的颜色梯度方向,然后在float域算出端点初值,再做量化。
这里有个容易忽略的细节:端点不一定是RGB888。ASTC规范为LDR定义了多种端点精度模式,从每通道4bit到8bit不等,每种模式占用的bit数不同,会直接影响权重量化可用的bit数。astcenc_pick_best_endpoint_format.cpp里做了个动态规划式的选择:给定总bit预算,决定颜色通道和权重通道各分多少bit最划算。
量化本身也是个反复迭代的过程。astcenc先粗量化端点,再根据量化误差调整端点,通常会有两三次收敛循环。源码里有个细节:循环终止条件不只是“误差足够小”,还会看“本轮提升比例是否低于阈值”,避免无意义迭代。读代码时我感触最深的是,很多加速技巧并不是什么高深理论,而是“大概率不会被选中的模式直接跳过”这种工程直觉。
3.4 权重搜索:硬件解码时到底怎么插值texel
ASTC解压时,硬件的做法是:拿到两个端点颜色,再根据每个texel的权重值做插值。权重本身也有分辨率——block内并不存储每个texel的权重,而是存一个低分辨率权重网格,解码时用双线性插值放大到block尺寸。权重网格的尺寸由block大小和模式共同决定,块越大,权重分辨率相对越低,这也是大块容易糊的原因。
所以编码器的另一项核心工作是:为每个texel选择最优权重值。astcenc的astcenc_ideal_endpoints_and_weights.cpp实现了两步走:先在float域算出每个texel的理想权重,然后做权重量化,量化步长和端点精度联合优化。复杂的地方在于,权重网格被插值放大后,每个texel的实际权重是相邻网格点权重的加权和,修改一个网格点会影响一片texel。astcenc解决这个约束的方法是把问题建模成最小二乘拟合,再迭代修正,这也是编码器耗时的重灾区之一。
3.5 打包输出:128bit的排列艺术
压缩完成后,symbolic_compressed_block要转成physical_compressed_block写盘/传输。ASTC的bitstream布局非常紧凑,block mode、分区字段、颜色端点模式、端点数据、权重数据全部是用位域拼出来的。astcenc_integer_sequences.cpp负责整数序列的编码——ASTC用了一种特殊的变长整数排列(Bounded Integer Sequence Encoding,简称BISE),能在一个固定长度bitsream里高效存储多个数值。虽然ASTC规范公开了算法,但读取多个配置项、处理边界条件时非常容易错,astcenc的实现照顾了各种组合的合法性与非法值检测,这部分代码注释也很详细。
理解bitstream布局对图形项目有一点实用价值:如果你需要写自己的ASTC查看器或调试工具,能直接从块里解析出分区号、端点模式、权重值,就不必每次依赖编码器解压,排查问题会快很多。
4. 架构优化审计:NEON/SIMD路径、线程池与内存策略
4.1 SIMD:NEON不是“可选项”,是“必选项”
打开Source/astcenc_vecmathlib_neon_*.h,你会发现整个向量库被设计成一套纯粹的内部DSL:vfloat4、vint4这些类型封装了NEON的float32x4_t和int32x4_t,然后重载了加减乘除、点积、比较、选值等操作。算法层几乎不直接写汇编或intrinsic,而是通过这套封装去表达。
为什么要这么做?一是跨架构复用逻辑代码,同一套算法代码在x86上编到SSE路径,在ARM上编到NEON路径,只有底层封装不同;二是编译器更容易优化,起吗比手工写vld1q_f32要可读得多。但这就带来一个编译关键点:如果没开启-DASTCENC_ISA_NEON=ON,代码会退回到一个标量实现,性能差距极其明显。我在同一台RK3588设备上对比过,开NEON之后编码速度提升了将近3倍,某些搜索密集型路径能到4倍以上。所以ARM设备上集成astcenc时,第一件事就是确认编译宏确实生效,可以用astcenc --version看编译时启用的指令集。
代码里另一个SIMD优化重点是点积与距离计算。分区搜索、端点拟合、权重误差评估这些核心循环都要大量计算颜色向量之间的距离。NEON的vdotq_f32一条指令做成4通道点积,比标量循环快得不是一星半点。astcenc甚至还会把多个texel打包成更大的向量同时算,充分压榨向量单元。
4.2 多线程:块级任务并行背后的调度细节
astcenc的并行策略是“块级并行”:每一个texture block的编码过程完全独立,互不依赖,天然适合丢给多个线程并行处理。astcenc_platform.cpp里实现的线程池很轻量:用一个原子计数器维护一个工作队列,线程从队列中取出block index开始干活。没有复杂的任务依赖关系,所以没有锁竞争热点,扩展性很好。
实际使用中,线程数设置我建议等于或略小于物理核心数。设太多并不会更快,反而会因为线程切换和内存争夺导致性能回退。astcenc默认线程数返回的是std::thread::hardware_concurrency(),但有些移动设备的大核小核差别很大,硬并发数高不代表编码更快。我在项目中一般固定为移动芯片的功耗核数或在线程池里按最低可用核心数来限制。
4.3 内存与缓存:为什么编码器吃满CPU但内存起伏不大
看到这个编码器疯狂跑分,你可能会担心内存爆掉。其实astcenc的内存策略相对保守:每个线程只维护自己处理block所需的临时缓冲,比如image_block、候选列表、权重缓存等;主线程持有整张图像的副本。一个6x6的block数据量很小,单线程临时缓冲也就几十KB级别,8个线程加一起也不会到MB量级。真正占内存的反而是输入图像本身以及输出缓冲——这两个是线性增长的。
源码里值得借鉴的设计是“对象复用”。astcenc在编码循环外预分配好所有临时区,进入循环后只复用不重新申请。这很符合图形项目对帧内内存抖动的零容忍态度。如果读者自己写压缩工具,我建议也采用这种预分配策略,避免每处理一个block都产生堆分配。
5. 图形项目落地:集成方案与质量调参
5.1 命令行工具接入构建流水线
最简单的落地方式是把官方命令行工具当作构建流水线中的一个步骤。压缩纹理通常不会在运行时做,而是放在资产管理阶段。以虚幻引擎的BuildCookRun或Unity的AssetPostprocessor为例,都可以挂自定义纹理处理步骤。
一个典型的命令行调用是这个样子:
astcenc -c source.png dest.astc 6x6 -medium -th 8参数很容易理解:-c表示压缩,输入输出文件名之后紧跟block size,-medium是质量档位,-th 8指定8线程。如果要保留alpha,默认就会编码RGBA;如果源图是LDR,记得用-cl或-cs明确颜色空间,避免不必要的线性化处理。命令行工具还有一堆诊断输出,比如压缩后的PSNR,非常适合快速验证参数改动的影响。
5.2 作为静态库集成进自研工具链
如果你有自己的DCC工具或自动化压缩服务,直接把astcenc编成静态库接进来更顺。核心API很少,先初始化上下文,然后循环调用压缩接口:
astcenc_config config; astcenc_config_init(ASTCENC_PREPROCESS_LDR, 6, 6, 1, ASTCENC_PRESET_MEDIUM, 0, 0, &config); config.thread_count = 4; astcenc_context* ctx; astcenc_context_alloc(&config, &ctx); astcenc_image image; image.dim_x = width; image.dim_y = height; image.dim_z = 1; // 排列data指针到RGBA的float数组 std::vector<uint8_t> out(width * height * 16 / 36); // block压到128bit size_t out_bytes = 0; astcenc_compress_image(ctx, &image, out.data(), out.size(), &out_bytes); astcenc_context_free(ctx);注意这里输出buffer大小的计算方式取决于block size:每个block占16字节,block数量是ceil(width/block_x) * ceil(height/block_y),所以用((width + bx - 1) / bx) * ((height + by - 1) / by) * 16最稳妥。API的线程数通过config传入,内部自己创建线程池,不需要你在外面套线程池。还有一点:输出的字节序是打包好的.astc文件内容,直接写文件就行,GPU上传时需要额外解析成硬件能读的block布局(通常.astc文件内容就是原始数据,但不同平台可能要求有序调整)。
5.3 质量参数选择基准:我的实测对比
参考代码里的文档加我自己的经验,对不同质量档位做了一个对比实验(测试图是一张2K分辨率、细节较丰富的游戏场景截图,块尺寸6x6):
| 质量档位 | 相对编码耗时 | 输出PSNR(RGB) | 适用场景 |
|---|---|---|---|
| -veryfast | 1x | 约33.1 dB | 快速预览、CI冒烟测试 |
| -fast | 1.6x | 约34.0 dB | 批量打包、低配开发机 |
| -medium | 3.2x | 约34.7 dB | 默认推荐、日常构建 |
| -thorough | 7.5x | 约35.2 dB | 发布前最终资源、高画质需求 |
| -exhaustive | 20x+ | 约35.3 dB | 稀有情况、特定高价值资源 |
数字未必对每种内容都成立,但趋势是稳定的:从fast到thorough,耗时涨了四五倍,PSNR只涨不到1dB。如果你不是做3A级画质,-medium基本就够;如果项目包体已经比较紧张,-fast的损失也还在可接受范围。关键是不要无脑上-exhaustive,它带来的收益跟耗时完全不成比例。
5.4 产物验证:与GPU解码器一致性检查
压缩完的.astc文件不能只靠肉眼看,还要做一致性验证。astcenc的解压路径和GPU硬件解码指令是严格遵循同一份ASTC规范的,所以可以用astcenc -d dest.astc preview.png解回PNG,再和原始图像算PSNR或SSIM。
但注意:CPU软件的参考解码和GPU硬件解码在数学上应该一致,因为ASTC规范明确定义了每个bit的语义。实际项目中如果有不一致,通常是纹理上传时数据布局搞错——比如block size填错、byte alignment不对、mip级别设置了非2的幂等。遇到这种问题,调试办法是先加载单张无mip的.astc图直接采样,排除上传代码的干扰。
还有个小技巧:法线贴图这类需要双线性插值的纹理,压缩后有时会在陡峭边缘出现奇怪的“断线”瑕疵,这是双平面(dual-plane)模式导致的。astcenc有个-db参数可以关闭dual-plane,对法线图推荐开启这个限制,换来更稳定的插值质量。
6. 源码审计中发现的高风险点与常见坑
6.1 特殊内容表现:随机噪声和纯色块的编码陷阱
对随机噪声图做ASTC编码时,任何编码器都会很挣扎,astcenc也不例外。噪声没有结构性,无论怎么选分区和端点都很难逼近原始值,结果就是“明明压了,PSNR却奇低”。我在审计时间曲线时发现,噪声图中-thorough的耗时能比普通场景图高出一倍多——因为搜索算法怎么跳都找不到满意解,只能把候选模式全跑一遍。
遇到这种纹理(比如特效里的动态噪点或风场噪声贴图),我建议要么换更大的块尺寸接受更低的画质,要么干脆保留非压缩格式,因为这种纹理通常尺寸小、采样频率不高,压缩收益有限。
另一个极端是纯色块。纯色块按理说是最好压的,但如果你看内部计算,反而有几个特殊分支专门处理“所有texel颜色相同”的情况——它直接走一个快速路径,输出一个最低成本的端点组合。如果一张大面积纯色纹理让编码器慢得异常,多半是预处理时颜色值有微小抖动(比如从PNG读入时出现0/255边界差异),导致所有texel并不完全相同。
6.2 线程数与编码质量的关系:没有副作用,但要留意功耗
线程数设置不会改变编码结果。因为每块独立编码,结果与任务切分方式无关,所以多线程只是并行计算,不会引入不确定性。多次用相同输入跑出来的bitstream是逐字节一致的。
但移动平台上线程数不能贪婪。移动CPU的大小核架构意味着开满所有核心会导致“大核过热降频”或“小核算半天”,反而拖慢整体。我的建议是:CPU大核数的一半到三分之二之间,或者根据设备电量/温控策略动态调整。例如8核芯片设4线程、6核设3线程,不会明显增加耗时,但能避免发热降频导致的性能悬崖。
6.3 版本升级与兼容性:压缩文件会不会“过时”
ASTC格式本身是公开规范,ASTC解码不支持“版本兼容”这个概念——只要符合规范的bitstream,任何支持ASTC的GPU都能解。所以之前压好的.astc文件不会因编码器更新而失效。
但astcenc自身的API可能会在小版本之间变化,特别是配置结构体astcenc_config里字段的增减和语义调整。如果项目内嵌了astcenc库,强烈建议在构建系统里锁定版本,不要把CI里用的版本和本地开发的不一致。我在一次升级中踩过坑:从3.x升到4.x时,命令行工具的-esw参数语义有调整,旧构建脚本没有跟着改,压出来一批质量不对的纹理,排查了很久才定位到。
6.4 我对项目改进的几点建议
审计完整个源码,结合集成经验,有几点可以分享给准备接入的项目:
第一,加内容hash缓存。相同源纹理、相同参数、相同编码器版本的压缩产物是确定的,可以在构建缓存里以源文件hash为key缓存结果,能省掉大量重复压缩时间。这对于大项目动辄几千张纹理的构建非常友好。
第二,为不同纹理类别预设参数模板。常用法线图、UI图、漫反射图分别配置不同的块尺寸和质量档位,比全局统一参数更能平衡画质和包体。
第三,把PSNR验证嵌入CI。如果团队对纹理质量有硬性要求,写一个脚本在批量压缩后自动抽样计算PSNR,超过阈值告警,防止某个编辑器版本或参数变更悄悄劣化资产质量。
回到开头的问题,如果你问我“这个编码器值不值得读源码”,我的回答非常笃定:值得。不只是因为它写得清晰、逻辑紧凑,更重要的是它完整展示了一个“从图像像素到硬件可解压bitstream”的完整链路,这在图形学领域是很稀缺的参考实现。读完之后,你再看ASTC这个格式,就不再是“一个压缩选项”,而是能看清它每一步取舍的来龙去脉。这种理解,在实际项目里遇到纹理质量问题或者编码性能瓶颈时,能帮你省下大把排查时间。