模块名 PKM2PNG 看起来挺直白:把 .pkm 文件转换成 PNG。但真正拿到这类文件时,很多人会卡在“不知道这是什么格式、为什么普通看图工具打不开、里面到底存的是不是像素数据”这几个问题上。我第一次在资源目录里看到 .pkm 后缀的文件时,也是反复试了各种工具才搞明白,这玩意儿是 Android 平台最常见的压缩纹理格式之一,大量游戏 APK 和大型应用里的贴图都用它。后来我干脆把解码逻辑整理成了到现在还在用的一个小工具 PKM2PNG,专门负责把 GPU 压缩纹理还原成通用 PNG,方便直接查看、二次编辑和批量归档。这篇文章就把完整的格式拆解、解码实现、工具封装和踩过的坑都记录下来,适合正在做 Android 逆向、游戏资源提取、Unity 贴图优化,或者单纯被某个 .pkm 文件难住的人。1. PKM 格式拆解:先搞清楚你拿到的是什么1.1 纹理压缩和普通图片压缩不是一回事网上最常见的误解就是一看到 .pkm 就把它当图片格式,想直接改名成 .png 再用看图软件打开,结果必然是失败的。这里得先理清一个概念:PNG、JPEG、WebP 这一类是“图片编码格式”,解码后得到的是完整的像素数组,CPU 可以线性解码;而 .pkm 里装的是 ETC1 或 ETC2 压缩纹理,数据被组织成一块块的“压缩块”,这些块必须在解码时还原成 4x4 的像素小块,才能拼出整张图。为什么 Android 资源里这么喜欢用 ETC1?因为它是 OpenGL ES 2.0 时代 Android 平台的基准纹理格式,所有 Android 设备都强制支持它。对游戏来说,贴图资源往往占包体的大头,ETC1 能把 RGB 纹理压缩到每像素 4 比特,即 2:1 到 4:1 的压缩比,纹理采样的带宽压力也小得多。相比之下 iOS 那边常用 PVRTC,别的平台还可能有 ASTC。于是看一个跨平台手游的资源目录,经常能看到同一张贴图在 Android 目录里是 .pkm,在 iOS 目录里是 .pvr,两个文件长得完全不一样。PKM 本身不是编码算法,它只是 ETC 压缩数据的一种打包容器。文件结构很简单:一个固定长度的文件头,后面紧跟压缩块数据。搞清楚这个文件头,解析工作就完成了一半。1.2 16 字节文件头逐字节拆解标准 PKM 文件头一共 16 字节,结构非常规整。前 6 个字节是 ASCII 魔数,内容是PKM 10,也就是十六进制的50 4B 4D 20 31 30。看到这个签名基本就能确认文件是 PKM 格式。接着是两个字节的版本号,一般是 2 或 3,分别对应 ETC1 和 ETC2 的常见打包版本。再往下就是四个大端序的 uint16 字段:原始宽度、原始高度、编码宽度、编码高度。一个实际文件的头部十六进制可能是这样的:50 4B 4D 20 31 30 02 00 00 01 00 80 00 01 00 80 PKM 10 ver originalW oriH encodedW encodedH按大端解析:00 01是 256,00 80是 128,所以这个文件就是一张 256x128 的纹理,编码尺寸也是 256x128。这里要注意的是,原始宽高和编码宽高不一定相等。ETC 压缩按 4x4 像素块进行,如果一张图宽度是 130,就得把编码尺寸补齐到 132,解码后还要裁掉多出来的 2 像素黑边,这个细节我在后面的排查部分会专门说。用 Python 解析文件头的代码非常短,我就是靠它把后续所有逻辑串起来的:import struct def parse_pkm_header(data: bytes) -> dict: if data[0:6] != b"PKM 10": raise ValueError("不是标准 PKM 文件") version, orig_w, orig_h, enc_w, enc_h = struct.unpack(">HHHHH", data[6:16]) return { "version": version, "original_width": orig_w, "original_height": orig_h, "encoded_width": enc_w, "encoded_height": enc_h, }1.3 压缩数据区的长度计算文件头之后就是压缩块数据。每一块编码 4x4 像素,占用 8 字节,所以数据区的长度理论上等于:(编码宽度 // 4) * (编码高度 // 4) * 8如果解析出来的头部显示编码宽高都是 256x128,那数据区长度就是(256 // 4) * (128 // 4) * 8 = 16384字节。拿到文件后可以先校验一下实际文件大小是不是等于16 + 16384,如果不是,说明文件可能被拼接了 mipmap 链,或者带了其他附加数据,后面解码时的偏移量就得重新调整。这是很多现成脚本容易忽略的地方,我一开始也在这里翻过车。2. 解码思路与工具选型2.1 为什么必须自己处理解码逻辑既然 PKM 是压缩纹理,那能不能直接调用系统 API 解压?能,但不太适合做批量和跨平台处理。OpenGL ES 支持 ETC1 纹理上传,但接口只面向 GPU,你得创建一个纹理对象,把数据喂进去,再通过渲染管线读回来,这种流程在一个命令行工具里非常别扭。而且 ETC1 解码是纯 CPU 就能做的操作,没必要引入图形上下文。更直接的原因是:市面上现成的转换工具要么只支持单个文件,要么已经多年没人维护,遇到 ETC2 的 PKM 经常解码出来一片花屏。与其在图形 API 和旧工具之间反复横跳,不如自己实现一个轻量级转换器,遇到问题也能直接看代码排查。2.2 方案选型对比我在动手前对比了几条实现路径:方案优点缺点C/C++ 手写解码器性能最好,贴近底层编译和跨平台分发麻烦,调试周期长Python 脚本迭代快,生态齐全逐块循环解码较慢,需要优化在线转换工具零安装,上手快批量不方便,私有资源有泄露风险,格式版本支持不透明Node/JavaScript 实现和前端工具链好集成图像输出阶段依赖较多最终我选择 Python 作为主实现语言。理由很简单:这个工具的核心瓶颈是解码逻辑的正确性,而不是运行时性能。Python 能让我最快地验证文件头解析、单块解码、颜色重建这些关键环节,等逻辑稳定之后再做性能优化也完全来得及。至于解码内核,我没有真的从零开始写,而是参考了一个成熟开源实现的位运算逻辑,自己重新组织成适合学习调试的版本,再补上文件头解析、PNG 输出和批量处理这些工程化部分。这里有个经验:算法实现可以用别人的,文件格式的边界逻辑一定要自己掌握,因为真正遇到的坑几乎都出在边界场景上。2.3 自己实现解码器的边界判断我见过不少人在“要不要从零实现”这个问题上纠结很久。我的建议是:如果你只是为了拿到图片资源,直接用现成库完全没问题,但要验证它是否正确,验证方法很简单,找一张尺寸明确、颜色简单的 PKM 纹理解码出来,再用像素级对比工具检查输出;如果颜色通道错位,说明库的字节序处理和你手里的文件不一致,这时候就必须深入理解 ETC 块的位布局才能判断问题出在哪。我最后确定的方案是:核心的单块解码自己维护,文件级解析和批量封装自己写,同时预留一个“外部解码器”接口,如果遇到 ETC2 这种 Python 生态支持不太好的格式,可以无缝切到 C 实现。这样一个工具既能处理日常 90% 的 ETC1 场景,又不会在特殊格式面前完全无能为力。3. 核心实现:ETC1 解码器的完整解析3.1 ETC1 编码原理速览ETC1 的解码思想可以用一个不太严谨但很好懂的类比来描述:把一张图拆成许多 4x4 的“瓷砖”,每一块瓷砖烧制时只记录两个基础颜色和一组“微调方向”,解码时根据每个像素的微调量,在基础颜色上做加减,最终还原出 16 个像素的颜色。因为是粗略重建颜色,所以 ETC1 是有损压缩,调试时别指望解码结果和原始图片像素级一致。具体来说,每个 4x4 像素块对应 8 字节压缩数据。这 8 字节分成两部分:前 4 字节是控制数据,后 4 字节是像素数据。控制数据里包含:两个子块各自的基础颜色,每子块一个 RGB;一个颜色编码模式标志,叫 diff,表示基础颜色是按“独立模式”还是“差分模式”存储;一个子块排布标志,叫 flip,表示两个子块是上下排列还是左右排列;两个 3 位表索引,每个子块一个,用于从调制值表里选出一组 4 个微调量。基础颜色有两种存法。独立模式里,每个子块的颜色分量各用 4 位存储;差分模式里,第一个子块的颜色分量各用 5 位存储,第二个子块的颜色分量存的是相对第一个子块的有符号差值,范围大概是 -4 到 3。差分模式把两个颜色的 24 位总空间省下来一点,但增加了编码复杂度,所以游戏纹理里两种模式都很常见。每个子块根据表索引从调制表中取一行。调制表有 8 行,每行 4 个数,典型值像这样:MODIFIER_TABLE = [ [2, 8, -2, -8], [5, 17, -5, -17], [9, 29, -9, -29], [13, 42, -13, -42], [18, 60, -18, -60], [24, 80, -24, -80], [33, 106, -33, -106], [47, 183, -47, -183], ]每一个像素在压缩数据里占 2 位,这 2 位索引指向调制表行里的 4 个数之一。最终颜色就是基础颜色加上对应的微调量。2 位能表示 4 个索引,所以哪怕一整个子块只有一种基础颜色,也能通过微调量还原出一定程度的颜色变化,不至于全是色块。3.2 单个 4x4 块的解码流程解码一个块的输入是 8 字节压缩数据,输出是 16 个 RGB 像素。核心流程是:先解析控制数据里的 diff、flip、两个表索引和基础颜色,再把像素数据按 2 位一组取出来,最后遍历 16 个位置计算最终颜色。这里要特别说明一点:不同来源的 PKM 文件在像素位的排布上可能存在差异。我在参考实现中按“前 16 位给第一个子块、后 16 位给第二个子块,子块内按 2 位一行取索引”的方式来处理,这也是很多解码器的通用约定。如果你的文件来自某个特殊打包工具,解码出现颜色错位,优先检查这一段的位序约定。import struct def _expand_5bit(value: int) -> int: return (value << 3) | (value >> 2) def _expand_4bit(value: int) -> int: return (value << 4) | value def decode_etc1_block(block: bytes, out: bytearray, dst_x: int, dst_y: int, width: int): ctrl = struct.unpack(">I", block[0:4])[0] pix = struct.unpack(">I", block[4:8])[0] diff = (ctrl >> 7) & 1 flip = (ctrl >> 6) & 1 table0 = ctrl & 7 table1 = (ctrl >> 3) & 7 colors = ctrl >> 8 if diff: r0 = (colors >> 19) & 0x1F g0 = (colors >> 14) & 0x1F b0 = (colors >> 9) & 0x1F dr = (colors >> 6) & 7 dg = (colors >> 3) & 7 db = colors & 7 dr = dr - 8 if dr & 4 else dr dg = dg - 8 if dg & 4 else dg db = db - 8 if db & 4 else db r1 = max(0, min(31, r0 + dr)) g1 = max(0, min(31, g0 + dg)) b1 = max(0, min(31, b0 + db)) r0, g0, b0 = _expand_5bit(r0), _expand_5bit(g0), _expand_5bit(b0) r1, g1, b1 = _expand_5bit(r1), _expand_5bit(g1), _expand_5bit(b1) else: r0 = (colors >> 20) & 0x0F g0 = (colors >> 16) & 0x0F b0 = (colors >> 12) & 0x0F r1 = (colors >> 8) & 0x0F g1 = (colors >> 4) & 0x0F b1 = colors & 0x0F r0, g0, b0 = _expand_4bit(r0), _expand_4bit(g0), _expand_4bit(b0) r1, g1, b1 = _expand_4bit(r1), _expand_4bit(g1), _expand_4bit(b1) base_colors = [(r0, g0, b0), (r1, g1, b1)] mod_tables = [MODIFIER_TABLE[table0], MODIFIER_TABLE[table1]] pix0 = (pix >> 16) & 0xFFFF pix1 = pix & 0xFFFF indices = [] for i in range(8): indices.append((pix0 >> (14 - i * 2)) & 3) for i in range(8): indices.append((pix1 >> (14 - i * 2)) & 3) for local_y in range(4): for local_x in range(4): subblock = 0 if (local_x // 2 == 0 if flip else local_y // 2 == 0) else 1 if flip == 0: subblock = 0 if local_y < 2 else 1 else: subblock = 0 if local_x < 2 else 1 idx_in_sub = (local_y % 2) * 4 + (local_x % 2) if flip == 0 else (local_y % 4) * 2 + (local_x % 2) texel_index = subblock * 8 + idx_in_sub mod_idx = indices[texel_index] r, g, b = base_colors[subblock] mod = mod_tables[subblock][mod_idx] r = max(0, min(255, r + mod)) g = max(0, min(255, g + mod)) b = max(0, min(255, b + mod)) px = dst_x + local_x py = dst_y + local_y offset = (py * width + px) * 3 out[offset] = r out[offset + 1] = g out[offset + 2] = b这个函数看起来长,但每一段逻辑都对应前面说过的压缩字段。注意几点:一是差分模式下基础颜色要把 5 位扩展到 8 位,二是 3 位差值转有符号数时要在 4 到 7 之间减 8,三是对最终 RGB 值做范围截断,因为基础颜色加上微调量之后可能越过 0 到 255 的边界。3.3 整图解码与 PNG 输出单块解码搞定后,整图解码就只是遍历所有块的问题了。按编码宽高计算出有多少个 4x4 块,然后从文件偏移 16 的位置开始,依次读取 8 字节块,解码后写入输出缓冲区的对应位置。from PIL import Image def pkm_to_png(input_path: str, output_path: str): with open(input_path, "rb") as f: data = f.read() header = parse_pkm_header(data[:16]) enc_w = header["encoded_width"] enc_h = header["encoded_height"] rgb = bytearray(enc_w * enc_h * 3) offset = 16 for by in range(enc_h // 4): for bx in range(enc_w // 4): block = data[offset:offset + 8] decode_etc1_block(block, rgb, bx * 4, by * 4, enc_w) offset += 8 img = Image.frombytes("RGB", (enc_w, enc_h), bytes(rgb)) if header["original_width"] != enc_w or header["original_height"] != enc_h: img = img.crop((0, 0, header["original_width"], header["original_height"])) img.save(output_path, "PNG")这段代码已经是一个能跑的转换器了。等宽高都是 4 的倍数时,原始尺寸和编码尺寸相同,直接输出就行。如果原始宽高不是 4 的倍数,解码图四周会有补齐的黑边,裁剪逻辑必须保留,否则拿到的图尺寸和预期不一致。4. 完整转换器的组装与实操4.1 命令行工具设计只做一个单文件转换函数还不够,实际用的工具要能处理批量文件。我给 PKM2PNG 封装了命令行入口,支持三种模式:单个文件转换、整个目录批量转换、递归处理子目录。命令设计成下面这样,尽量贴近日常使用习惯:python pkm2png.py input.pkm -o output.png python pkm2png.py ./textures/ -o ./converted/ --recursive参数解析用标准库的 argparse 就能搞定。唯一的考量是递归模式,因为很多游戏资源目录结构很深,贴图分散在不同子目录里。我把输出目录结构也尽量保持和输入一致,避免转换完还要手动整理。处理完文件后,工具会打印每个文件的原始尺寸、编码尺寸、版本号和输出路径。这个信息看起来不起眼,但在批量发现异常文件时非常有用,能一眼看出哪些文件的编码尺寸远大于原始尺寸,可能是带 mipmap 链的问题文件。4.2 实际跑通的一次转换我用一个尺寸为 256x128 的测试用 PKM 文件验证流程。文件头解析结果一切正常:魔数是PKM 10,版本号为 2,原始宽高和编码宽高都是 256x128。用上面那套解码函数跑完后,输出 PNG 的宽高是 256x128,颜色分布也符合预期。验证时有一个好用的对照方法:如果源纹理内容本身是接近纯色的渐变图,解码结果中同一区域的颜色波动应该很小;如果解码正确,两个相邻像素不会出现突兀的颜色跳变。如果解码出来某一块区域全是诡异颜色,再配合十六进制看文件头版本号,基本能立刻确定是 ETC2 数据被当成 ETC1 解了。4.3 批量处理和性能优化心得单文件转换在大图上会有点慢,因为 Python 逐块循环处理 256x256 的图就是 4096 个块,每块还要做一堆位运算。换成 2048x2048 的图,循环次数直接翻 64 倍,解码耗时肉眼可见增加。我的优化手段很简单:用多进程按文件并行,而不是在单文件内做复杂向量化。每张图的解码互相独立,用进程池可以接近线性提速。如果觉得 Python 的解码速度还是不够,可以在单块解码函数里改用 numpy 对整块数据做向量化操作,或者用 C 语言单独写一个解码内核,通过绑定调用。我在实际项目里把核心解码路径下沉到了 C,Python 只负责文件解析和结果整理,性能提升了十倍以上,代价是编译流程复杂了一点点,但对于动辄上百张贴图的批量任务来说很值得。5. 常见问题与排查实录5.1 解码出来是花的,颜色完全错乱这是最常见的坑。我总结下来原因基本是三类:一是文件根本不是 ETC1,而是 ETC2 或其他纹理格式,却用了 ETC1 解码;二是控制字或者像素字的字节序读反了,大端文件被当小端读,或者反过来;三是子块划分时 flip 位判断反了,导致两个子块的基础颜色和像素位置对应不上。遇到花屏先别急着改算法,做两件事:先看文件头版本号,版本号高于 2 的很可能需要 ETC2 解码器;再找一张尺寸很小的 PKM 文件当测试样本,小图花屏更容易通过肉眼定位问题区域,比在大图上大海捞针高效得多。5.2 图像周围出现黑边前面提过,ETC 压缩按 4x4 块进行,当原始图片宽高不是 4 的倍数时,打包工具会把编码尺寸向上补齐。解码结果自然就带了一圈填充区域,这些区域没有真实颜色数据,通常是黑色或者重复边缘像素。解决办法就是在输出前用文件头里的原始宽高做裁剪,而不是直接保存整个编码尺寸。5.3 ETC2 版本和 Alpha 通道问题ETC2 是 ETC1 的扩展,支持了 RGBA 和更高质量的颜色编码,但它和 ETC1 的块格式不兼容,不能混用。PKM 文件头版本号高于 2 时,基本可以断定是 ETC2 数据。另外,如果文件头魔数不是PKM 10,而是一些变体签名,也要停下来确认格式,避免在错误方向上浪费时间。遇到带 Alpha 的纹理时,解码输出必须是 RGBA 而不是 RGB,早期版本支持 RGBA 的编码格式很多,自己写解码器时要把 RGBA 输出通道预留好,纯 RGB 输出会在半透明贴图资源上丢失大量阴影和边缘信息。5.4 解码方向反了,上下颠倒PKM 文件里的纹理坐标方向和使用场景有直接关系。有些引擎导出纹理时习惯原点在左下角,有些则采用左上角原点。把它当普通图片输出时,会因为坐标约定不同出现上下颠倒。这不是解码错误,而是坐标约定差异。解决办法是给工具加一个--flip-y参数,根据资源使用场景决定要不要在输出前翻转图像,而不是武断地把翻转逻辑硬编码进去。5.5 大图解码性能太慢性能和正确性不是一回事,但实际使用中性能问题会直接影响效率。如果只是偶尔转一两张图,Python 版本完全够用;如果要做整包资源的批量提取,就必须考虑并行或调用底层解码器。我的建议顺序是:先确认解码正确,再上多进程,最后根据实际耗时决定要不要下沉 C 内核。过早优化只会让调试变复杂,没有实际收益。5.6 一些工具使用中的小经验我遇到的一个比较诡异的问题是某些 PKM 文件的数据区里混了 mipmap 链,也就是一张大图后面跟着一串逐级缩小的缩略图数据。文件头只描述第一级,如果按单图方式解析,数据区长度对不上,解码结果也会在某个位置出现乱码。排查时先对比文件实际大小和头算出来的理论大小,不吻合就按多级纹理方式处理。另外,从游戏资源里提取的 PKM 文件有时会和实际用途不匹配,比如存了一张预乘 Alpha 的贴图,直接解码输出后叠加到明亮背景上会看到黑边,这种情况问题不在解码器,而在资源管线。当时我把这个情况也记进了工具的日志提示里,避免自己过两天又疑惑为什么转换结果边缘发黑。最后再分享一点个人体会PKM2PNG 这个工具从开始写到稳定使用,过程其实不算复杂,真正耗时间的不是解码算法,而是对各种异常文件边界的梳理。ETC 纹理格式的官方资料不算少,但要落地成工具,还得靠一堆实际文件做对照验证。我的经验是:遇到不认识的文件扩展名,第一件事永远是把二进制头完整 dump 出来,看懂前 16 字节,后面的事情都水到渠成。这个思路后来也用在了其他各种格式解析上,几乎没踩过重复的大坑。如果你手里正好有一堆 .pkm 文件没地方下手,不妨按这篇文章的流程自己写一遍,跑通一次转换之后,你对压缩纹理的理解会提升一个层级。
模块名 PKM2PNG 看起来挺直白:把 .pkm 文件转换成 PNG。但真正拿到这类文件时,很多人会卡在“不知道这是什么格式、为什么普通看图工具打不开、里面到底存的是不是像素数据”这几个问题上。我第一次在资源目录里看到 .pkm 后缀的文件时,也是反复试了各种工具才搞明白,这玩意儿是 Android 平台最常见的压缩纹理格式之一,大量游戏 APK 和大型应用里的贴图都用它。后来我干脆把解码逻辑整理成了到现在还在用的一个小工具 PKM2PNG,专门负责把 GPU 压缩纹理还原成通用 PNG,方便直接查看、二次编辑和批量归档。这篇文章就把完整的格式拆解、解码实现、工具封装和踩过的坑都记录下来,适合正在做 Android 逆向、游戏资源提取、Unity 贴图优化,或者单纯被某个 .pkm 文件难住的人。1. PKM 格式拆解:先搞清楚你拿到的是什么1.1 纹理压缩和普通图片压缩不是一回事网上最常见的误解就是一看到 .pkm 就把它当图片格式,想直接改名成 .png 再用看图软件打开,结果必然是失败的。这里得先理清一个概念:PNG、JPEG、WebP 这一类是“图片编码格式”,解码后得到的是完整的像素数组,CPU 可以线性解码;而 .pkm 里装的是 ETC1 或 ETC2 压缩纹理,数据被组织成一块块的“压缩块”,这些块必须在解码时还原成 4x4 的像素小块,才能拼出整张图。为什么 Android 资源里这么喜欢用 ETC1?因为它是 OpenGL ES 2.0 时代 Android 平台的基准纹理格式,所有 Android 设备都强制支持它。对游戏来说,贴图资源往往占包体的大头,ETC1 能把 RGB 纹理压缩到每像素 4 比特,即 2:1 到 4:1 的压缩比,纹理采样的带宽压力也小得多。相比之下 iOS 那边常用 PVRTC,别的平台还可能有 ASTC。于是看一个跨平台手游的资源目录,经常能看到同一张贴图在 Android 目录里是 .pkm,在 iOS 目录里是 .pvr,两个文件长得完全不一样。PKM 本身不是编码算法,它只是 ETC 压缩数据的一种打包容器。文件结构很简单:一个固定长度的文件头,后面紧跟压缩块数据。搞清楚这个文件头,解析工作就完成了一半。1.2 16 字节文件头逐字节拆解标准 PKM 文件头一共 16 字节,结构非常规整。前 6 个字节是 ASCII 魔数,内容是PKM 10,也就是十六进制的50 4B 4D 20 31 30。看到这个签名基本就能确认文件是 PKM 格式。接着是两个字节的版本号,一般是 2 或 3,分别对应 ETC1 和 ETC2 的常见打包版本。再往下就是四个大端序的 uint16 字段:原始宽度、原始高度、编码宽度、编码高度。一个实际文件的头部十六进制可能是这样的:50 4B 4D 20 31 30 02 00 00 01 00 80 00 01 00 80 PKM 10 ver originalW oriH encodedW encodedH按大端解析:00 01是 256,00 80是 128,所以这个文件就是一张 256x128 的纹理,编码尺寸也是 256x128。这里要注意的是,原始宽高和编码宽高不一定相等。ETC 压缩按 4x4 像素块进行,如果一张图宽度是 130,就得把编码尺寸补齐到 132,解码后还要裁掉多出来的 2 像素黑边,这个细节我在后面的排查部分会专门说。用 Python 解析文件头的代码非常短,我就是靠它把后续所有逻辑串起来的:import struct def parse_pkm_header(data: bytes) -> dict: if data[0:6] != b"PKM 10": raise ValueError("不是标准 PKM 文件") version, orig_w, orig_h, enc_w, enc_h = struct.unpack(">HHHHH", data[6:16]) return { "version": version, "original_width": orig_w, "original_height": orig_h, "encoded_width": enc_w, "encoded_height": enc_h, }1.3 压缩数据区的长度计算文件头之后就是压缩块数据。每一块编码 4x4 像素,占用 8 字节,所以数据区的长度理论上等于:(编码宽度 // 4) * (编码高度 // 4) * 8如果解析出来的头部显示编码宽高都是 256x128,那数据区长度就是(256 // 4) * (128 // 4) * 8 = 16384字节。拿到文件后可以先校验一下实际文件大小是不是等于16 + 16384,如果不是,说明文件可能被拼接了 mipmap 链,或者带了其他附加数据,后面解码时的偏移量就得重新调整。这是很多现成脚本容易忽略的地方,我一开始也在这里翻过车。2. 解码思路与工具选型2.1 为什么必须自己处理解码逻辑既然 PKM 是压缩纹理,那能不能直接调用系统 API 解压?能,但不太适合做批量和跨平台处理。OpenGL ES 支持 ETC1 纹理上传,但接口只面向 GPU,你得创建一个纹理对象,把数据喂进去,再通过渲染管线读回来,这种流程在一个命令行工具里非常别扭。而且 ETC1 解码是纯 CPU 就能做的操作,没必要引入图形上下文。更直接的原因是:市面上现成的转换工具要么只支持单个文件,要么已经多年没人维护,遇到 ETC2 的 PKM 经常解码出来一片花屏。与其在图形 API 和旧工具之间反复横跳,不如自己实现一个轻量级转换器,遇到问题也能直接看代码排查。2.2 方案选型对比我在动手前对比了几条实现路径:方案优点缺点C/C++ 手写解码器性能最好,贴近底层编译和跨平台分发麻烦,调试周期长Python 脚本迭代快,生态齐全逐块循环解码较慢,需要优化在线转换工具零安装,上手快批量不方便,私有资源有泄露风险,格式版本支持不透明Node/JavaScript 实现和前端工具链好集成图像输出阶段依赖较多最终我选择 Python 作为主实现语言。理由很简单:这个工具的核心瓶颈是解码逻辑的正确性,而不是运行时性能。Python 能让我最快地验证文件头解析、单块解码、颜色重建这些关键环节,等逻辑稳定之后再做性能优化也完全来得及。至于解码内核,我没有真的从零开始写,而是参考了一个成熟开源实现的位运算逻辑,自己重新组织成适合学习调试的版本,再补上文件头解析、PNG 输出和批量处理这些工程化部分。这里有个经验:算法实现可以用别人的,文件格式的边界逻辑一定要自己掌握,因为真正遇到的坑几乎都出在边界场景上。2.3 自己实现解码器的边界判断我见过不少人在“要不要从零实现”这个问题上纠结很久。我的建议是:如果你只是为了拿到图片资源,直接用现成库完全没问题,但要验证它是否正确,验证方法很简单,找一张尺寸明确、颜色简单的 PKM 纹理解码出来,再用像素级对比工具检查输出;如果颜色通道错位,说明库的字节序处理和你手里的文件不一致,这时候就必须深入理解 ETC 块的位布局才能判断问题出在哪。我最后确定的方案是:核心的单块解码自己维护,文件级解析和批量封装自己写,同时预留一个“外部解码器”接口,如果遇到 ETC2 这种 Python 生态支持不太好的格式,可以无缝切到 C 实现。这样一个工具既能处理日常 90% 的 ETC1 场景,又不会在特殊格式面前完全无能为力。3. 核心实现:ETC1 解码器的完整解析3.1 ETC1 编码原理速览ETC1 的解码思想可以用一个不太严谨但很好懂的类比来描述:把一张图拆成许多 4x4 的“瓷砖”,每一块瓷砖烧制时只记录两个基础颜色和一组“微调方向”,解码时根据每个像素的微调量,在基础颜色上做加减,最终还原出 16 个像素的颜色。因为是粗略重建颜色,所以 ETC1 是有损压缩,调试时别指望解码结果和原始图片像素级一致。具体来说,每个 4x4 像素块对应 8 字节压缩数据。这 8 字节分成两部分:前 4 字节是控制数据,后 4 字节是像素数据。控制数据里包含:两个子块各自的基础颜色,每子块一个 RGB;一个颜色编码模式标志,叫 diff,表示基础颜色是按“独立模式”还是“差分模式”存储;一个子块排布标志,叫 flip,表示两个子块是上下排列还是左右排列;两个 3 位表索引,每个子块一个,用于从调制值表里选出一组 4 个微调量。基础颜色有两种存法。独立模式里,每个子块的颜色分量各用 4 位存储;差分模式里,第一个子块的颜色分量各用 5 位存储,第二个子块的颜色分量存的是相对第一个子块的有符号差值,范围大概是 -4 到 3。差分模式把两个颜色的 24 位总空间省下来一点,但增加了编码复杂度,所以游戏纹理里两种模式都很常见。每个子块根据表索引从调制表中取一行。调制表有 8 行,每行 4 个数,典型值像这样:MODIFIER_TABLE = [ [2, 8, -2, -8], [5, 17, -5, -17], [9, 29, -9, -29], [13, 42, -13, -42], [18, 60, -18, -60], [24, 80, -24, -80], [33, 106, -33, -106], [47, 183, -47, -183], ]每一个像素在压缩数据里占 2 位,这 2 位索引指向调制表行里的 4 个数之一。最终颜色就是基础颜色加上对应的微调量。2 位能表示 4 个索引,所以哪怕一整个子块只有一种基础颜色,也能通过微调量还原出一定程度的颜色变化,不至于全是色块。3.2 单个 4x4 块的解码流程解码一个块的输入是 8 字节压缩数据,输出是 16 个 RGB 像素。核心流程是:先解析控制数据里的 diff、flip、两个表索引和基础颜色,再把像素数据按 2 位一组取出来,最后遍历 16 个位置计算最终颜色。这里要特别说明一点:不同来源的 PKM 文件在像素位的排布上可能存在差异。我在参考实现中按“前 16 位给第一个子块、后 16 位给第二个子块,子块内按 2 位一行取索引”的方式来处理,这也是很多解码器的通用约定。如果你的文件来自某个特殊打包工具,解码出现颜色错位,优先检查这一段的位序约定。import struct def _expand_5bit(value: int) -> int: return (value << 3) | (value >> 2) def _expand_4bit(value: int) -> int: return (value << 4) | value def decode_etc1_block(block: bytes, out: bytearray, dst_x: int, dst_y: int, width: int): ctrl = struct.unpack(">I", block[0:4])[0] pix = struct.unpack(">I", block[4:8])[0] diff = (ctrl >> 7) & 1 flip = (ctrl >> 6) & 1 table0 = ctrl & 7 table1 = (ctrl >> 3) & 7 colors = ctrl >> 8 if diff: r0 = (colors >> 19) & 0x1F g0 = (colors >> 14) & 0x1F b0 = (colors >> 9) & 0x1F dr = (colors >> 6) & 7 dg = (colors >> 3) & 7 db = colors & 7 dr = dr - 8 if dr & 4 else dr dg = dg - 8 if dg & 4 else dg db = db - 8 if db & 4 else db r1 = max(0, min(31, r0 + dr)) g1 = max(0, min(31, g0 + dg)) b1 = max(0, min(31, b0 + db)) r0, g0, b0 = _expand_5bit(r0), _expand_5bit(g0), _expand_5bit(b0) r1, g1, b1 = _expand_5bit(r1), _expand_5bit(g1), _expand_5bit(b1) else: r0 = (colors >> 20) & 0x0F g0 = (colors >> 16) & 0x0F b0 = (colors >> 12) & 0x0F r1 = (colors >> 8) & 0x0F g1 = (colors >> 4) & 0x0F b1 = colors & 0x0F r0, g0, b0 = _expand_4bit(r0), _expand_4bit(g0), _expand_4bit(b0) r1, g1, b1 = _expand_4bit(r1), _expand_4bit(g1), _expand_4bit(b1) base_colors = [(r0, g0, b0), (r1, g1, b1)] mod_tables = [MODIFIER_TABLE[table0], MODIFIER_TABLE[table1]] pix0 = (pix >> 16) & 0xFFFF pix1 = pix & 0xFFFF indices = [] for i in range(8): indices.append((pix0 >> (14 - i * 2)) & 3) for i in range(8): indices.append((pix1 >> (14 - i * 2)) & 3) for local_y in range(4): for local_x in range(4): subblock = 0 if (local_x // 2 == 0 if flip else local_y // 2 == 0) else 1 if flip == 0: subblock = 0 if local_y < 2 else 1 else: subblock = 0 if local_x < 2 else 1 idx_in_sub = (local_y % 2) * 4 + (local_x % 2) if flip == 0 else (local_y % 4) * 2 + (local_x % 2) texel_index = subblock * 8 + idx_in_sub mod_idx = indices[texel_index] r, g, b = base_colors[subblock] mod = mod_tables[subblock][mod_idx] r = max(0, min(255, r + mod)) g = max(0, min(255, g + mod)) b = max(0, min(255, b + mod)) px = dst_x + local_x py = dst_y + local_y offset = (py * width + px) * 3 out[offset] = r out[offset + 1] = g out[offset + 2] = b这个函数看起来长,但每一段逻辑都对应前面说过的压缩字段。注意几点:一是差分模式下基础颜色要把 5 位扩展到 8 位,二是 3 位差值转有符号数时要在 4 到 7 之间减 8,三是对最终 RGB 值做范围截断,因为基础颜色加上微调量之后可能越过 0 到 255 的边界。3.3 整图解码与 PNG 输出单块解码搞定后,整图解码就只是遍历所有块的问题了。按编码宽高计算出有多少个 4x4 块,然后从文件偏移 16 的位置开始,依次读取 8 字节块,解码后写入输出缓冲区的对应位置。from PIL import Image def pkm_to_png(input_path: str, output_path: str): with open(input_path, "rb") as f: data = f.read() header = parse_pkm_header(data[:16]) enc_w = header["encoded_width"] enc_h = header["encoded_height"] rgb = bytearray(enc_w * enc_h * 3) offset = 16 for by in range(enc_h // 4): for bx in range(enc_w // 4): block = data[offset:offset + 8] decode_etc1_block(block, rgb, bx * 4, by * 4, enc_w) offset += 8 img = Image.frombytes("RGB", (enc_w, enc_h), bytes(rgb)) if header["original_width"] != enc_w or header["original_height"] != enc_h: img = img.crop((0, 0, header["original_width"], header["original_height"])) img.save(output_path, "PNG")这段代码已经是一个能跑的转换器了。等宽高都是 4 的倍数时,原始尺寸和编码尺寸相同,直接输出就行。如果原始宽高不是 4 的倍数,解码图四周会有补齐的黑边,裁剪逻辑必须保留,否则拿到的图尺寸和预期不一致。4. 完整转换器的组装与实操4.1 命令行工具设计只做一个单文件转换函数还不够,实际用的工具要能处理批量文件。我给 PKM2PNG 封装了命令行入口,支持三种模式:单个文件转换、整个目录批量转换、递归处理子目录。命令设计成下面这样,尽量贴近日常使用习惯:python pkm2png.py input.pkm -o output.png python pkm2png.py ./textures/ -o ./converted/ --recursive参数解析用标准库的 argparse 就能搞定。唯一的考量是递归模式,因为很多游戏资源目录结构很深,贴图分散在不同子目录里。我把输出目录结构也尽量保持和输入一致,避免转换完还要手动整理。处理完文件后,工具会打印每个文件的原始尺寸、编码尺寸、版本号和输出路径。这个信息看起来不起眼,但在批量发现异常文件时非常有用,能一眼看出哪些文件的编码尺寸远大于原始尺寸,可能是带 mipmap 链的问题文件。4.2 实际跑通的一次转换我用一个尺寸为 256x128 的测试用 PKM 文件验证流程。文件头解析结果一切正常:魔数是PKM 10,版本号为 2,原始宽高和编码宽高都是 256x128。用上面那套解码函数跑完后,输出 PNG 的宽高是 256x128,颜色分布也符合预期。验证时有一个好用的对照方法:如果源纹理内容本身是接近纯色的渐变图,解码结果中同一区域的颜色波动应该很小;如果解码正确,两个相邻像素不会出现突兀的颜色跳变。如果解码出来某一块区域全是诡异颜色,再配合十六进制看文件头版本号,基本能立刻确定是 ETC2 数据被当成 ETC1 解了。4.3 批量处理和性能优化心得单文件转换在大图上会有点慢,因为 Python 逐块循环处理 256x256 的图就是 4096 个块,每块还要做一堆位运算。换成 2048x2048 的图,循环次数直接翻 64 倍,解码耗时肉眼可见增加。我的优化手段很简单:用多进程按文件并行,而不是在单文件内做复杂向量化。每张图的解码互相独立,用进程池可以接近线性提速。如果觉得 Python 的解码速度还是不够,可以在单块解码函数里改用 numpy 对整块数据做向量化操作,或者用 C 语言单独写一个解码内核,通过绑定调用。我在实际项目里把核心解码路径下沉到了 C,Python 只负责文件解析和结果整理,性能提升了十倍以上,代价是编译流程复杂了一点点,但对于动辄上百张贴图的批量任务来说很值得。5. 常见问题与排查实录5.1 解码出来是花的,颜色完全错乱这是最常见的坑。我总结下来原因基本是三类:一是文件根本不是 ETC1,而是 ETC2 或其他纹理格式,却用了 ETC1 解码;二是控制字或者像素字的字节序读反了,大端文件被当小端读,或者反过来;三是子块划分时 flip 位判断反了,导致两个子块的基础颜色和像素位置对应不上。遇到花屏先别急着改算法,做两件事:先看文件头版本号,版本号高于 2 的很可能需要 ETC2 解码器;再找一张尺寸很小的 PKM 文件当测试样本,小图花屏更容易通过肉眼定位问题区域,比在大图上大海捞针高效得多。5.2 图像周围出现黑边前面提过,ETC 压缩按 4x4 块进行,当原始图片宽高不是 4 的倍数时,打包工具会把编码尺寸向上补齐。解码结果自然就带了一圈填充区域,这些区域没有真实颜色数据,通常是黑色或者重复边缘像素。解决办法就是在输出前用文件头里的原始宽高做裁剪,而不是直接保存整个编码尺寸。5.3 ETC2 版本和 Alpha 通道问题ETC2 是 ETC1 的扩展,支持了 RGBA 和更高质量的颜色编码,但它和 ETC1 的块格式不兼容,不能混用。PKM 文件头版本号高于 2 时,基本可以断定是 ETC2 数据。另外,如果文件头魔数不是PKM 10,而是一些变体签名,也要停下来确认格式,避免在错误方向上浪费时间。遇到带 Alpha 的纹理时,解码输出必须是 RGBA 而不是 RGB,早期版本支持 RGBA 的编码格式很多,自己写解码器时要把 RGBA 输出通道预留好,纯 RGB 输出会在半透明贴图资源上丢失大量阴影和边缘信息。5.4 解码方向反了,上下颠倒PKM 文件里的纹理坐标方向和使用场景有直接关系。有些引擎导出纹理时习惯原点在左下角,有些则采用左上角原点。把它当普通图片输出时,会因为坐标约定不同出现上下颠倒。这不是解码错误,而是坐标约定差异。解决办法是给工具加一个--flip-y参数,根据资源使用场景决定要不要在输出前翻转图像,而不是武断地把翻转逻辑硬编码进去。5.5 大图解码性能太慢性能和正确性不是一回事,但实际使用中性能问题会直接影响效率。如果只是偶尔转一两张图,Python 版本完全够用;如果要做整包资源的批量提取,就必须考虑并行或调用底层解码器。我的建议顺序是:先确认解码正确,再上多进程,最后根据实际耗时决定要不要下沉 C 内核。过早优化只会让调试变复杂,没有实际收益。5.6 一些工具使用中的小经验我遇到的一个比较诡异的问题是某些 PKM 文件的数据区里混了 mipmap 链,也就是一张大图后面跟着一串逐级缩小的缩略图数据。文件头只描述第一级,如果按单图方式解析,数据区长度对不上,解码结果也会在某个位置出现乱码。排查时先对比文件实际大小和头算出来的理论大小,不吻合就按多级纹理方式处理。另外,从游戏资源里提取的 PKM 文件有时会和实际用途不匹配,比如存了一张预乘 Alpha 的贴图,直接解码输出后叠加到明亮背景上会看到黑边,这种情况问题不在解码器,而在资源管线。当时我把这个情况也记进了工具的日志提示里,避免自己过两天又疑惑为什么转换结果边缘发黑。最后再分享一点个人体会PKM2PNG 这个工具从开始写到稳定使用,过程其实不算复杂,真正耗时间的不是解码算法,而是对各种异常文件边界的梳理。ETC 纹理格式的官方资料不算少,但要落地成工具,还得靠一堆实际文件做对照验证。我的经验是:遇到不认识的文件扩展名,第一件事永远是把二进制头完整 dump 出来,看懂前 16 字节,后面的事情都水到渠成。这个思路后来也用在了其他各种格式解析上,几乎没踩过重复的大坑。如果你手里正好有一堆 .pkm 文件没地方下手,不妨按这篇文章的流程自己写一遍,跑通一次转换之后,你对压缩纹理的理解会提升一个层级。