ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

astcenc源码审计:移动端ASTC纹理压缩与GPU带宽优化

astcenc源码审计:移动端ASTC纹理压缩与GPU带宽优化 移动端项目做性能专项时我几乎每次都会在RenderDoc里盯着纹理带宽那几栏待上半天。采样的纹理数量少说几十张每张如果还是RGBA32直出带宽压力说出来都是泪。ASTCAdaptive 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平均8bit12x12块尺寸下每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_TYPERelease -DASTCENC_ISA_NEONON 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 权重搜索硬件解码时到底怎么插值texelASTC解压时硬件的做法是拿到两个端点颜色再根据每个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 SIMDNEON不是“可选项”是“必选项”打开Source/astcenc_vecmathlib_neon_*.h你会发现整个向量库被设计成一套纯粹的内部DSLvfloat4、vint4这些类型封装了NEON的float32x4_t和int32x4_t然后重载了加减乘除、点积、比较、选值等操作。算法层几乎不直接写汇编或intrinsic而是通过这套封装去表达。为什么要这么做一是跨架构复用逻辑代码同一套算法代码在x86上编到SSE路径在ARM上编到NEON路径只有底层封装不同二是编译器更容易优化起吗比手工写vld1q_f32要可读得多。但这就带来一个编译关键点如果没开启-DASTCENC_ISA_NEONON代码会退回到一个标量实现性能差距极其明显。我在同一台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::vectoruint8_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质量档位相对编码耗时输出PSNRRGB适用场景-veryfast1x约33.1 dB快速预览、CI冒烟测试-fast1.6x约34.0 dB批量打包、低配开发机-medium3.2x约34.7 dB默认推荐、日常构建-thorough7.5x约35.2 dB发布前最终资源、高画质需求-exhaustive20x约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这个格式就不再是“一个压缩选项”而是能看清它每一步取舍的来龙去脉。这种理解在实际项目里遇到纹理质量问题或者编码性能瓶颈时能帮你省下大把排查时间。
返回列表