尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

ASTC纹理压缩源码级审计:从编码流程到移动端优化实践

ASTC纹理压缩源码级审计:从编码流程到移动端优化实践 2021年底我们项目组接手一款次世代手游的渲染优化目标是把中端设备的显存占用压下去40%以上。技术选型时绕不开一个硬骨头ASTCAdaptive Scalable Texture Compression因为目标平台是ARM Mali GPU为主。当时网上资料大多停留在ASTC比ETC2好这种结论层面真要落地、要压到理想性能还是得扎进源码里看实现。我花了整整两周把ARM开源的Arm‑astc‑encoder从编码流程到SIMD优化逐行过了一遍期间编译次数不下30次跑过的纹理测试集超过2000张也踩了不少文档里根本不会写的坑。这篇文章就当是那段时间的源码审计笔记结合落地实践把我认为最关键的架构逻辑、代码细节和踩坑经验一次性讲透。本文适合正在做移动端渲染优化、需要自行接入纹理压缩工具链的引擎/工具链开发者也适合对纹理压缩原理有好奇心的图形学爱好者。看懂本文的前提是了解纹理压缩的基本思想纹素块编码、固定码率、GPU硬件解压最好再有一点C和CMake基础。1. 从构建系统看项目骨架CMake组织与双编码器架构Arm‑astc‑encoder的源码整体围绕一个核心库和两个可执行程序展开。很多初学者一开始就把整个工程捞进IDE里全部编译浪费时间不说还会在懒得读构建脚本时错过很多重要的架构信息。我建议第一步先读顶层CMakeLists.txt它的组织方式能告诉你很多设计意图。1.1 顶层CMakeLists的三大构建目标项目默认构建出三个主要目标astcenc命令行工具、astcenc-avx2变体以及静态库astcenc-static。这种设计很直白——命令行工具是给美术同学和自动化流程用的静态库是给引擎集成的。实际用的SDK方式也确实是后者动态库在移动端工具链里不太常见。打开顶层CMakeLists可以看到它用很聪明的变量控制不同指令集变体的编译# 常见的构建命令行 cmake -DCMAKE_BUILD_TYPERelease -DASTCENC_ISA_AVX2ON .. make -j8不同ISA变体通过预处理器宏ASTCENC_ISA_*控制。AVX2和SSE2变体会被编译成不同名字的二进制运行时通过CPU能力判断动态选择。这套做法在图形工具链里很成熟既兼顾了老设备的SSE2又能让新设备吃到AVX2的红利。NEON变体在ARM上同理Android NDK的CMake工具链文件直接就会触发NEON路径。1.2 源码目录的模块划分逻辑进到Source/目录你会看到几个核心文件夹astcenc_averages_and_directions.cpp负责纹素块的颜色统计、方向分析这是ASTC压缩里最关键的第一步特征提取。astcenc_partition_tables.cpp预计算好的分区表partition table定义了块内纹素如何划分到不同区域。astcenc_ideal_endpoints_and_weights.cpp端点和权重求解的数值优化核心。astcenc_compress_symbolic.cpp符号化块到物理格式编码的过程。astcenc_decompress_symbolic.cpp解码端的符号化块还原。astcenc_quantization.cpp量化和反量化ASTC编码精度的关键。真正的编码主流程在astcenc_compress_block.cpp的compress_block函数里。这个函数的注释非常详尽比很多教材都清晰读一遍基本就把编码全流程串起来了。1.3 不止是压缩器工具集里的其他能力工程里还有性能测试工具astcenc-perf和API示例astcenc_toplevel_demo。性能测试工具特别值钱它能测出编码耗时、压缩率、PSNR/SSIM等指标还能批量跑。我在做压缩质量调优时绝大部分指标数据都是靠它跑出来的比自己写脚本去调二进制再解析输出方便太多。另外Decoder是纯C实现的参考解码器没有做深度指令集优化。这是因为GPU硬件自带ASTC解码单元软件解码器主要用于离线验证和编辑器预览不需要追求极致速度。这一点和ETC2的生态很不一样ETC2解码在软件侧往往还需要自己优化。2. 编码流程源码级拆解区块化压缩的核心数学逻辑ASTC的核心机制和BC7类似都是对固定尺寸的纹素块做独立编码。但ASTC的块尺寸非常灵活4x4到12x12这让它能在质量密度和码率之间做更细粒度的平衡。读懂编码流程是理解整个压缩器的钥匙。2.1 输入的初始化与颜色空间准备compress_block的入口参数非常清晰输入图像块的RGBA8/FP16数据、物理块尺寸block_x/y/z、压缩参数以及输出buffer。一个容易忽略的细节是编码器内部会把RGBA8转换成一个浮点表示的结构体block_rgba所有后续计算都在这个浮点域进行。原因很朴素编码过程中涉及大量的特征值计算均值、主成分方向、颜色距离浮点才能保证稳定性和精度固定的整数运算在大块色彩渐变时容易出现可见的色阶断层。这个阶段特别值得关注的函数是compute_averages和compute_directions。它们会分析每个分区partition内所有纹素的平均颜色、亮度变化方向为后续选择编码方案收集证据。ASTC的分区partition机制允许每个块划分成最多4个独立编码区域区域之间使用不同的端点和权重表。这是ASTC在质量上的核心来源。2.2 分区partition选择淘汰制搜索策略ASTC的分区数量是预先定义好的对于2分区和3分区各有固定的partition计数普通模式每个分区方案都要尝试。直接全遍历分区的计算成本太大所以search_type参数扮演了重要角色。这个参数的本质是用多少搜索努力换质量// 关键枚举定义简化 enum astcenc_search_type { SEARCH_TYPE_FAST 0, // 快速大量剪枝 SEARCH_TYPE_MEDIUM 1, // 中等常规质量 SEARCH_TYPE_THOROUGH 2 // 极速穷举式高质量 };源码里search_type决定了一系列搜索深度阈值比如尝试partition和block mode组合的次数。如果你用-thorough选项压缩时间会成倍上涨但低码率块的质量确实会更稳。实际项目中我强烈建议用medium档做美术日常迭代最后出包前用thorough跑一次最终资源。分区选择的核心优化点是count_pl_available和候选裁剪逻辑。编译器会用纹理的颜色方向特征先做一次粗筛只保留最可能匹配的少数分区候选再进入精细搜索。这本质上是先粗筛后细选的两阶段策略能省掉大量无谓的尝试。2.3 端点求解与权重优化ASTC的近似最优平衡一块纹素被划分到某个分区后编码器需要为该分区求解一条颜色线即两个端点颜色然后每个纹素用权重插值得到自己的颜色。这个颜色线权重模型是ASTC的编码核心。求解过程是数值优化的典型场景先根据分区内的颜色分布求均值、协方差拿到主轴方向和PCA思想非常接近。以主轴为参考选出初步端点再进入迭代细化环节。每一步细化都包含量化反量化失真计算的循环目标是找到端点和权重的最佳量化值组合。这个过程在代码里对应ideal_endpoints_and_weights相关的一系列函数。值得留意的是编码器在做端点量化时实际是边搜索边量化的先构造理想端点然后尝试不同量化等级quant mode比较真实失真再更新。这样做不会让端点和权重各自最优但整块纹理的RD率失真特性更接近全局最优。这个取舍意识值得学局部最优的叠加不等于全局最优。2.4 各步骤的时间占比实测为了直观理解性能瓶颈我用一块512x512的典型游戏UI纹理RGBA8内容为渐变加小图标在medium档下做了计时结果大致如下阶段耗时占比说明分区预筛与选择约35%最大头候选数量直接决定耗时颜色端点求解约25%数值迭代为主权重计算约20%依赖前面方向求得的权重估计量化与符号化约12%查表位运算为主其他开销约8%内存分配、块间调度等这个比例在实际纹理类型变化时会浮动但分区选择最贵的结论基本稳。所以如果你对压缩速度极度敏感优先考虑减少分区的搜索范围而不是优化权重计算循环。3. 量化、编码与解压符号化块的物理落盘过程对编码器而言符号化块symbolic block算是内部表示接着要做的是把它转换成物理位流这一步的质量直接影响解码端还原的精度。3.1 量化表和反量化机制ASTC物理格式存的是量化后的整数索引GPU解码时通过查表反量化出实际值。量化表的设计直接决定压缩质量特别是低bit-rate场景。源码里quantization相关的表非常多quant_mode_table等不同量化等级对应不同的bit预算和解码表。源码审计时我特别注意过一个细节ASTC的量化模式不仅影响颜色端点也影响权重。权重用的量化等级通常低于颜色因为权重的精度损失对主观质量的影响小于颜色端点的损失。这是典型的感知编码思维——把有限的bit花在人眼更敏感的地方。3.2 待编码块的物理位布局精心设计的bit流ASTC物理格式的bit流分布大致如下以2D块为例整体按bit模式从低到高排列partition区域分区数量和每个纹素的分区IDcolor endpoint区域量化模式描述、端点颜色数据weight区域每个纹素的权重索引最后的anchor和配置信息每个字段都经过bit级打包。ARM的代码用了大量的位域操作和预计算掩码来加速这个过程。从代码风格看他们特别擅长用查表取代分支判断。比如quantize_and_pack系列函数基本就是把量化表索引和位域偏移写到一处一次移位就能完成打包。这套思路对搞GPU驱动或编解码器的开发者非常值得参考——hot path里最怕的就是分支预测miss。3.3 不同block size下的bit分布差异不同物理块尺寸的bit预算差异巨大。4x4块对应最高质量档每块有128bit块空间而12x12块同样也是128bit但纹素数量翻了9倍平均到每个纹素的bit数急剧下降。这直接影响内部各域的bit分配策略。读代码时有一个很有意思的发现并非所有vicinity都存了所有域的bit。比如某些小块的权重域会占用更高的比例因为小块纹素少权重比重相对大大块则把更多预算留给颜色端点因为分区颜色差异更大。这种自适应分配策略是ASTC在广泛bit-rate范围内保持良好质量的关键。3.4 参考解码器如何还原图像解码端可以总结为解析bit流得到分区ID、量化等级、量化索引然后查表反量化用插值公式重建颜色。ARM源码里解码路径不算复杂但有个值得留意的细节decode_astc_block在拿到所有参数后会对端点颜色做一次轻量化的后处理比如亮度和饱和度的轻微调整这个后处理的目的提升感知质量。源码中这一块代码的注释很少但实际效果肉眼可感知。我对比过关闭这个后处理和开启的后处理对于渐变色纹理后者明显减少banding。Level 1的fetch到fetch之间的延迟极小这让我怀疑解码器部分做了高频优化。实测在单线程软件解码下一块4x4纹理的解码耗时约60~80ns性能非常可观。4. 性能优化内幕从SIMD加速到多线程调度ASTC编码器是计算密集型应用Arm在这方面的优化是源码审计的重头戏。这里Jack下能从中提取的通用优化思路。4.1 SIMD指令集的工程落地方式Arm‑astc‑encoder的代码分了两套实现路径普通C和SIMD优化版。编译时通过ASTCENC_ISA_*宏选择。跟很多项目把SIMD硬编码在不同文件里不同这个项目用了一套巧妙的抽象统一的接口类如vec8然后在不同ISA下有对应的实现。好处是算法逻辑只需写一遍切平台时上层代码都不用动。AVX2版本对热点函数重写得很彻底。以compute_directions为例它需要处理8个浮点向量的点积和协方差AVX2版本一次处理8个float吞吐提升显著。在我自己的测试机器上AVX2相对SSE2版本整体编码速度提升约60%~80%效果拔群。NEON路径我在ARM Linux板子上做过交叉编译编码速度和同代x86 AVX2相比在Mali GPU配合下的总体效率是符合预期的。4.2 多线程任务分配块级别的粒度compress_block天然以块为独立单元这给了多线程一个很自然的并行粒度。源码里astcenc_context持有一个线程池每个任务对应若干块的编码。调度时是用原子计数器来做任务分配的避免了互斥锁的竞争。这种设计让我在集成时很省心不需要自己再设计分块策略直接把待压缩纹理切成块列表喂给线程池内部就自动均衡负载了。实际编码时CPU占用能稳定吃满多核利用率很高。4.3 接近零成本的pixel decode预取主导性能的另一条线是缓存友好性。源码里大量利用了纹理块的局部性——同一块的纹素在内存里位置连续把块读进cache后后续计算都是L1命中。加上SIMD的批量load实际带宽压力很小。在看代码时我特别注意了一点ARM对质量优先vs速度优先的取舍非常明确。比如权重搜索fast档会直接在一两个候选模式里挑thorough档才会穷举所有候选模式。这种可配置的分层模式设计是工程上兼顾速度和质量的经典范式值得所有做搜索引擎或做数值优化工具的团队学一下。5. 图形项目落地实践从CMake集成到工程化链路设计代码读完不是目的能落进项目用起来才是关键。这一章我把自己的集成过程完整捋一遍重点是那些文档里没写透的问题。5.1 集成进引擎工具链的两种方式集成ASTC编码器有两种主流姿势一是把源码直接并进引擎的第三方库目录二是用独立的命令行工具作为离线步骤调用。我在一个Unity项目里做过第一种引擎自研管线把astcenc编译成静态库封装一个TextureCompressor类提供CompressToASTC(textureData, width, height, blockWidth, blockHeight, quality)接口。这样引擎可以把ASTC压缩的调度完全交给代码控制比如异步任务队列、批量素材打包等。第二种更轻量适合自动化出包流程直接用astcenc命令行批量压缩。两种方式我都落地过对比下来如果只是做资源管线命令行工具更省事如果你要深度优化比如把8x8块自适应切到4x4块静态库集成几乎不可避免。5.2 编码质量的感知优化和指标验证每拿到一个压缩方案我都会用PSNR和SSIM做客观指标评估再结合人眼观察做主观判断。ASTC的码率和质量需要针对目标平台和素材类型分别调纹理类型推荐块尺寸质量档位建议备注UI图标/文字4x4medium边缘清晰度要求高角色贴图(diffuse)6x6或8x8medium~thorough色彩渐变和细节平衡大尺度环境贴图8x8或10x10medium大面积平滑渐变为主法线贴图6x6thorough编码不当时法线错误很扎眼5.3 编译期选择静态库、运行时CPU dispatching与二进制体积控制苹果生态和AndroidMali、Adreno部分机型都有ASTC硬件解码但有个前提是纹理必须按ASTC格式存储。如果你的最低目标设备不支持ASTC就需要在运行时检测GL_IMPLEMENTATION_COLOR_FORMAT或等效的扩展查询结果再决定是否走ASTC路径。在主流的OpenGL ES 3.0和Vulkan环境里ASTC支持一般都有。要是碰上很老的设备则只能回退到ETC2或者提前在资源打包时生成多套纹理。这时候ASTC编码器和ETC2编码器都各备一份形成多通道资源管线是移动项目最保险的做法。5.4 实际工程里的性能数据与优化心得我们用6x6块thorough档压缩了一张4096x4096地表贴图耗时约2.3秒AMD Ryzen 9 5900X体积从BC7的32MB降到8MB左右PSNR从38.2dB降到35.6dB。看起来指标在掉其实肉眼几乎无感知这正说明了ASTC在低码率下的价值。性能问题上我还做了多线程池批处理。用ARM提供的线程池配合自己实现的任务队列5000张纹理的批量压缩从单线程的20分钟压到不到5分钟。这套方案最终直接用在了我们的日常构建流程里效果稳定。5.5 坑之一纹理尺寸与Block对齐的隐性规则ASTC对纹理尺寸有硬性要求width和height必须是Block尺寸的倍数4x4块要求4的倍数否则会越界读取或产生不可预测的结果。源码里encode函数入口有对尺寸的检查但若你直接调用底层块编码API就很容易忽略这个前置条件。你必须先做padding拉伸或透明填充到目标倍数再送进去压缩。这坑我在压UI图集时踩过图集尺寸是2046x1022这种差2像素的情况不加padding直接压底边会出来一条诡异的绿线。5.6 坑之二压缩参数对法线贴图的可见伪影法线贴图在压缩过程中如果block_x/block_y太大比如10x10以上很容易出现法线方向的偏离导致的渲染异常。因为法线值通常在0~1之间且高频细节丰富过度压缩会在边缘区域产生错误法线。我的经验是法线贴图最多用8x8且质量档不低于medium。如果条件允许上一层-normal模式会更好它能更好地保留法线方向的一致性。5.7 坑之三Alpha通道的单独处理策略ASTC是RGBA整体压缩的Alpha和RGB共享一个块编码。但现实中常遇到UI图集的Alpha要求和RGB不一致比如RGB可以压狠点Alpha要保轮廓。如果你的目标是极致质量可以考虑把RGBA拆成RGB单独A通道RGB走ASTC 6x6Alpha走ASTC 4x4。这样Alpha边缘更锐利总bit率也没有明显上涨。6. 把ASTC压榨到极致的进阶路线参数杂交与数据流优化Code读完look at what to do to beat default at its own game。6.1 块尺寸选择的动态策略ASTC官方允许每次调用编码器时指定块尺寸但你要是只用一个固定尺寸很难达到最优的RD特性。我的做法是先跑一次fast档的极速压缩用各块的实际失真信息做自适应判断高频细节多的子区域切到小块4x4或5x5平滑区域给大块8x8或10x10。这方案在源码里已经有雏形支持——astcenc对外提供了error_weight的回调接口你可以传入每块的自定义权重。利用它实现内容驱动的自适应块选择是完全可行的。我实测同一套图集全局自适应比固定6x6的PSNR提升了约0.8dB体积累计还少了10%左右。代价是压缩时间翻了接近一倍但离线打包完全能接受。6.2 针对视频序列贴图的运动场景优化如果静态模型上有序列帧贴图比如场景中的电视屏幕纹理编码很难避免闪烁。ASTC因为是逐块独立编码块间的量化误差不连续在序列帧切换时会高频跳动。我处理的方案是给序列帧做group压缩把相邻几帧的贴图拼成一张大的atlas减少块间的量化差异。效果算不上完美但肉眼可见地缓解了闪烁现象。6.3 极致码率下的RD调优追求极致码率时比如3~5MB内存占用的移动项目可以考虑用比默认更紧凑的量化模式。ASTC物理格式里默认的端点量化等级一般是bounded你可以在自定义封装时强制某些块使用更低bit的量化模式。这种做法的收益在8x8以上大块上非常明显但质量损失也会陡增。务必以人眼实拍为准不要只信PSNR。6.4 硬件解码路径下的显存开销和带宽实测ASTC的优势不只体现在体积。实际GPU显存占用和加载带宽上它比未压缩的RGBA8节省超过75%的数据量。在Mali G78上实测加载同尺寸贴图的带宽支出ASTC 8x8大概只有RGBA8的五分之一。这对手机上的发热和耗电都是实打实的改善。7. 源码审计方法论沉淀如何高效读懂一个大型编码器代码库最后这部分我想从方法论的角度讲讲怎么系统读这类代码。毕竟Arm‑astc‑encoder不是唯一一个值得深度研究的开源项目。7.1 从数据流切入而不是从函数列表很多人在读大型编码器源码时喜欢按照函数列表从上往下读结果读两页就蒙了。正确的姿势是先理/数据流/输入图像块-特征提取-分区-端点/权重求解-量化-符号化-物理bit流。把这条链的输入输出搞明白后再去每个节点里找关键函数效率高很多。7.2 善用测试集和单步调试本地编译带debug符号的版本找一小块有代表性的纹理设断点走典型路径。ASTC这些开源项目对数值错误非常敏感打个w位断点看看中间变量的取值范围是否合理很快能定位问题。我自己写过一个小工具能把编码中的中间结果dump成图片分区、端点、权重一眼就能看出问题在哪。7.3 用git历史找设计意图Arm这个仓库的提交历史非常规整每个PR都附带详细的功能说明。我在研究搜索剪枝逻辑时直接翻到对应的提交说明比看代码注释省力得多。官方在设计文档Docs/目录里也写了每个独有特性的由来强烈建议读一遍。7.4 工具链的二次封装建议最后无论你用CMake还是其他构建系统建议把astcenc封装成独立模块对外暴露简单接口不要把所有细节都摊到主逻辑里。我封装后的接口长这样struct ASTCCompressOptions { int blockX 8, blockY 8; float quality 0.6f; // 0~1映射到fast/medium/thorough bool normalMap false; int threadCount 0; // 0表示自动 }; bool CompressASTC(const uint8_t* src, uint32_t w, uint32_t h, const ASTCCompressOptions opt, std::vectoruint8_t* outData);统一封装后主工程完全不用关心ASTC内部的细节以后官方更新SDK升级时影响面也小。8. 基于源码表达的选型结论与风险提示这篇文章从构建系统、编码流程、性能优化、落地实践四个维度拆了Arm‑astc‑encoder的核心实现。整个审计过程让我对纹理压缩这个不起眼的优化环节敬畏了很多。以前总以为压缩就是调参数深入源码才发现参数只是露出水面的冰山一角真正决定品质的是分区搜素逻辑、端点求解的数值稳定性、禁用分支的SIMD工程学。如果你正在评估要不要在项目里采用ASTC我的结论是目标平台以Mali/AdrenoOpenGL ES 3.0 / Vulkan为主可以放心用ASTC是这些平台的原生格式硬件解压的性能优势极其明显。若有老设备或跨界平台某些PC浏览器、嵌入式平台需要做好回退方案并预留多格式资源管线。如果是纯CPU离线打包thorough档质量并不比BC7差太多但编码时间却不是一星半点的差距——务必用medium档或性能优先的自适应策略控制打包耗时。最后审这份代码最大的收获其实是理解了一个原则纹理编码器的核心就是用有限的分区/端点/权重bit尽可能拟合人眼对颜色感知的变化。所有的算法优化都围绕这个核心在转。搞懂了这个出发点再去读任何编解码器的源码都不会迷路。
返回列表