去年整理一个旧游戏工程时我在assets/textures/目录下发现了几百个.pkm后缀的文件。同事说这些是贴图但双击之后没有任何看图软件能打开。我用十六进制查看器扫了一眼前四个字节不是 PNG 的89 50 4E 47而是一串自定义魔数后面跟着一堆看起来像头部信息的字段。当时心里就明白了这八成是引擎内部打包的压缩纹理格式要写个解析工具才能转成 PNG。就这么折腾了几天搞出了 PKM2PNG 这个小工具顺便把整个转换链路摸了一遍。这类压缩纹理在移动端项目里非常常见。PKM 不是通用标准扩展名而是某自研引擎的资源管线为纹理压缩设计的私有容器。移动端 GPU 直接支持 ETC1、ETC2 这类压缩格式PNG 则是存储和传输格式无法直接上传 GPU。PKM 里存的是 GPU 可以直接采样的压缩块美术、策划甚至程序在排查资源问题时往往需要把 PKM 还原成 PNG 来看实际效果。这个工具主要解决的就是这个查看和转换问题。1. 这批.pkm文件到底是从哪来的先解释一下为什么会存在 PKM 这种格式不然你很可能在转换的时候产生一个困惑既然引擎最终要的是压缩纹理为什么不直接用 DDS 或者 KTX非要单独搞一个 PKM 容器PNG、JPG 这类图片格式从设计之初就是给人看的保存的是经过压缩编码的完整像素信息。GPU 不能直接采样 PNG需要 CPU 先解码成 RGBA再上传显存。对于动辄几十上百个 UI 图集的项目来说这种方案内存占用太高。ETC1、ETC2 这类压缩纹理格式则可以直接被 GPU 解码采样上传显存后占用的带宽和内存都小得多。PKM 是引擎为了统一管理这些压缩纹理在原始压缩数据外面包了一层头部信息方便资源系统识别尺寸、格式和数据块大小。所以 PKM 去掉头部之后剩下的数据其实可以直接喂给 GPU API 当成纹理数据用。我在那个旧工程里看到的 PKM主要是三大类UI 图集、场景光照贴图、模型贴图。UI 图集一般都带透明通道放到 PKM 里通常是 ETC2 RGBA 格式光照贴图通常只有 RGB很多用 ETC1模型贴图则比较杂有些甚至用了 ETC2 RGBA1。这个认知特别重要因为直接决定后面解码器的编写方式。如果你也拿到一批.pkm文件建议先用脚本统计一下文件头和大小分布不要急着写解码。这批文件之所以一直没被清理是因为旧美术资源已经找不到原始 Photoshop 工程目录只有打包后的形式。所以转换工具更像一个“考古工具”把 GPU 里的最终形态还原成可查看的图片用来确认槽位、UV 映射、透明通道是否正常。适合用到这个工具的人包括维护旧项目的程序、做资源还原的美术以及像我这样接手老工程的独立开发者。2. 先读懂PKM的容器再动手解码2.1 文件头里到底存了什么我拿到的这批 PKM 文件头部结构大致如下表所示。注意不同引擎的实现细节可能不同你实际处理的文件需要先自己核对魔数。偏移字节数字段说明04magic通常是PKM1之类的自定魔数44header_size头部总字节数常见 16 或 6484image_width原始图片宽度124image_height原始图片高度164format_id压缩格式编号1 表示 ETC12 表示 ETC2 RGB3 表示 ETC2 RGBA1204block_size单个压缩块大小ETC 系通常是 8 或 16 字节2440reserved保留字段可能是 mipmap 数量、原始文件哈希等很多网上流传的解析代码只读前 16 字节这在大多数场景下凑合能跑但一旦遇到头部带附加信息的 PKM就会把数据区起点算错导致最后出来的图片一片乱色。我当时花了一个晚上排查这个问题最后发现某个文件名以_lightmap结尾的 PKM 头部长度是 64 字节如果不先读取header_size就直接按固定 16 字节跳转整个数据区就全偏移了。用 Python 的struct解析头部很简单关键要加上校验逻辑。import struct from pathlib import Path def parse_pkm_header(path): with open(path, rb) as f: head f.read(64) magic, header_size, width, height, fmt, block_size struct.unpack(4sIIIII, head[:24]) assert magic in (bPKM1, bPKM2), f非预期魔数: {magic} assert header_size 16, f头部长度异常: {header_size} return { magic: magic, header_size: header_size, width: width, height: height, format_id: fmt, block_size: block_size, }解析之后先打印几条记录看看尺寸是否合理。宽度或高度如果出现 4096、0、65535 这类值十有八九是字节序或头部偏移算错了别急着进解码流程。2.2 数据区不是一行像素而是一块块矩阵PNG 是逐像素存储转换后图片尺寸和实际尺寸一致。PKM 则完全不同ETC 系列压缩以 4x4 像素块为单位一个块要么是 64 位ETC1要么是 128 位ETC2。解码时不能按从左到右逐行扫像素必须按块矩阵扫。块数量的计算方式import math block_count_x math.ceil(width / 4) block_count_y math.ceil(height / 4)这里有个很容易踩的细节如果原始贴图是 600x400实际编码时会被填充到 604x404 或者 608x400 再进入压缩流程。也就是说数据区里包含了一些超出原始宽高的像素块这些块在屏幕上是不会显示的。解码输出时一定要使用原始宽高裁剪否则图片侧面和底部会出现莫名脏色后面第五节我会专门展开。PKM 容器本质就是一个“带头的压缩纹理包”。把头部跳过之后剩下的字节排列顺序是第一行的所有块、第二行的所有块以此类推。每个块内再按压缩格式定义解码。理解这个排列规律后转换思路就清晰了解析头部 → 读取压缩数据 → 按块解码 → 组装 RGBA → 编码 PNG。3. 从像素块到PNG解码链路思路与代码骨架3.1 自己写解码器还是直接用现成库如果是第一次接触压缩纹理我推荐先尝试现成工具比如常见图形引擎配套的纹理转换命令行或开源纹理库的命令行工具。它们解析 ETC1、ETC2 已经非常成熟你只需要把 PKM 的头部剥掉把压缩数据喂进去即可。但有个现实问题这批 PKM 的特定头部结构可能与项目绑定通用命令行工具不一定认。你需要在工具外面再包一层“剥头”逻辑把 PKM 转成标准输入格式这本身也绕不开对头部的解析。如果想彻底搞清楚转换原理自己写一个简化版解码器也很有价值。ETC1 的原理简单说就是4x4 的块被拆成两个 2x4 的子块每个子块存一个基色修正值像素根据强度索引表恢复出近似 RGB。它是有损压缩所以转换出的 PNG 与原始 PNG 相比会有轻微色差但分辨率相同视觉上很难察觉。我实际项目里的选择是折中方案先自写 ETC1/ETC2 解码函数便于调试遇到显式格式问题可以直接分析数据同时对不支持的格式快速失败并输出报警。这样既能保证工具可控也避免为了一个冷门格式引入一整套庞大依赖。3.2 用 Pillow 组装 PNG 是最省心的路径解码得到的是一块块 RGBA 字节需要拼成完整图像再保存。Python 生态里 Pillow 是最省心的选择。它内部封装了 zlib 压缩和 PNG 过滤器选择我们可以只关注像素数据。当解码输出为raw_rgba字节串可以这样快速写出 PNGfrom PIL import Image def rgba_to_png(raw_rgba, width, height, output_path): image Image.frombytes(RGBA, (width, height), raw_rgba) image.save(output_path, formatPNG, optimizeTrue)如果源格式是 ETC2 RGBA1压缩块解码后直接得到 RGBA 数据直接保存即可。如果源是 ETC1 加独立 Alpha 通道解码器需要先解出 RGB 颜色块再单独解析 Alpha 块两者合并成 RGBA。这里最容易翻车的点是 Alpha 的解码方式不同后面第五节会讲。一个完整的单文件转换函数流程如下def convert_pkm_to_png(pkm_path, png_path): header parse_pkm_header(pkm_path) with open(pkm_path, rb) as f: f.seek(header[header_size]) texture_data f.read() rgba decode_texture(texture_data, header) rgba_to_png(rgba, header[width], header[height], png_path)其中decode_texture会先遍历所有 4x4 块根据每个块的压缩信息恢复像素颜色再根据原始宽高裁剪边界。写这部分代码时建议先在固定 8x8 的小图上验证把所有块解码器和对应的结果打印出来肉眼确认无误后再扩展到正常尺寸否则问题会被大图淹没定位起来很痛苦。4. PKM2PNG实战从单文件脚本到批量处理4.1 第一版先保证单个文件能转出来我第一个版本只写了四五十行功能非常简单传入一个.pkm路径输出一个同名.png。核心代码其实就几个函数第一部分运行顺利之后先别急着加命令行参数赶紧用一张已知内容的 UI 图集验证。怎么验证有两种方式。最直接的方式是把转换出来的 PNG 和游戏运行时的截图对比。截图里找同一个 UI 按钮观察颜色、边缘、字体是否正确。另一类是写一个校验脚本逐像素对比原图和转换图的 RGB 值统计最大误差。压缩纹理是有损格式误差通常很小但如果有几百个像素出现几十以上的偏差大概率是解码器有 bug。我第一版跑通后发现某个按钮边缘有很明显的黑圈。当时没想明白因为同一张图集的中心区域质感完全正常。后来才意识到是 Alpha 预乘问题这个我放在第五节展开。如果你只转一张图很可能观察不到这个现象建议第一版就转 5 张以上不同风格的贴图覆盖实心、半透明、渐变边缘等场景。4.2 第二版批量扫描并保留目录结构单个文件能转之后自然会遇到几百个文件挨个处理的场景。这个阶段我给工具加上了参数化入口--input assets/textures --output converted_images --input-format auto --jobs 4 --log processing.log其中--input-format auto是让我在解码前先自动判断format_id然后分流到不同解码器。这个很重要因为旧工程里的 PKM 常常是不同时期生成的混着 ETC1 和 ETC2如果写死一种格式转换到一半就会大量失败。批量扫描时我顺手记录了源文件相对路径输出目录中保持同样的层级结构。这样美术同学定位某个资源时可以直接按原始目录名逐层找下去不用重新根据命名推断路径。目录结构保留的另一个价值是方便后续写“资源变更检测”把同一路径下新旧 PNG 做对比就能知道哪些资源被改动过。4.3 第三版并行解码与内存控制解码过程看起来是文件读取和像素运算实际很吃内存。一张 2048x2048 的图RGBA 裸数据就有 16MB如果同时加载几十张内存轻松破 1GB。我在第三版引入了并发但限制同时解码的任务数。这里有个容易踩的坑Python 多线程在 CPU 密集场景下受益有限但我的解码函数底层有一部分调用 C 实现的解码扩展所以线程池依然能加速。如果纯粹都是 Python 字节操作就要考虑多进程。实际项目中我用的是进程池加固定并发数因为转换任务彼此独立进程模型更稳定也不会被 GIL 卡住。from concurrent.futures import ProcessPoolExecutor, as_completed def batch_convert(input_root, output_root, max_workers4): tasks [] for pkm_path in Path(input_root).rglob(*.pkm): rel_path pkm_path.relative_to(input_root) png_path Path(output_root) / rel_path.with_suffix(.png) tasks.append((pkm_path, png_path)) with ProcessPoolExecutor(max_workersmax_workers) as pool: futures [pool.submit(convert_one, src, dst) for src, dst in tasks] for future in as_completed(futures): future.result()并发打开的文件句柄也要控制ProcessPoolExecutor任务粒度是单张图片转换内部先读取完整数据再解码所以单任务内存上限可控。真正的内存峰值来自输出 PNG 后Pillow 保存时又复制了几份字节串大图转换时钩子会比较明显。我的建议是解码后的原始 RGBA 字节用完立即释放不要保留列表否则一个批次跑下来内存占用可能翻三倍。4.4 生成校验信息方便回溯批量转换之后怎么确认所有文件都转了、转得对我在每张 PNG 旁边生成了一个同名.meta.json记录源文件哈希、解码器版本、生成时间、压缩格式。有一个文本格式的校验文件后续排查问题就不需要重新扫描所有文件。{ source_hash: sha256:xxxx, decoder_version: pkm2png_v0.9.3, width: 1024, height: 1024, format_id: 2, generated_at: 2025-01-12T16:30:0008:00 }这个步骤看起来多此一举实际上在第五节的踩坑排查中帮了大忙。比如我发现某张图偏色直接看它的.meta.json就能确定它用的格式是 ETC1 还是 ETC2 RGBA1不必重新解析文件。以后如果要更新解码算法也能根据版本号筛选旧输出统一重转。5. 最容易翻车的几个坑按踩坑频率排序5.1 宽高不是 4 的倍数导致输出脏边ETC 系列压缩按 4x4 块处理所以原始宽高如果不是 4 的倍数引擎在压缩前会先把底边和右边填充到块边界。填充方式没有统一标准有的引擎填黑色有的填重复边缘像素有的填透明黑色。解码时这些填充像素会被还原成实际颜色值。如果你直接按填充后的尺寸输出 PNG图片边缘就会出现一圈莫名其妙的杂色。我当时遇到的情况是一张 600x400 的 UI 背景右侧宽出 2 像素底部高出 4 像素转换结果里右侧出现一条灰色竖线。排查了很久才发现是填充像素被写进了输出。解决方式很简单在解码完成后按原始width和height裁剪不要直接输出全部块区域。blocks_x math.ceil(width / 4) blocks_y math.ceil(height / 4) canvas_width blocks_x * 4 canvas_height blocks_y * 4 # 解码得到完整 canvas 后 rgba rgba[:width * 4 * height] # 按行裁剪到有效高度5.2 Premultiplied Alpha 导致的边缘黑圈压缩纹理在引擎里经常使用预乘 Alpha 来减少边缘漏色。预乘的含义是 RGB 通道已经乘以 Alpha 值透明区域 RGB 为 0半透明边缘 RGB 偏暗。PNG 标准通常期望非预乘 RGB所以直接转换后半透明边缘会出现深色描边、黑圈等视觉问题。第一次批量转换的 UI 按钮边缘就是这么翻车的。判断一个图是否预乘最直白的方法是找一块背景透明但前景有渐变像素的角落观察 RGB 是否随 Alpha 同步下降。如果是就需要做 unpremultiplydef unpremultiply(rgba_bytes): out bytearray(len(rgba_bytes)) for i in range(0, len(rgba_bytes), 4): r, g, b, a rgba_bytes[i:i4] if a ! 0: out[i] min(255, int(r * 255 / a)) out[i1] min(255, int(g * 255 / a)) out[i2] min(255, int(b * 255 / a)) else: out[i] out[i1] out[i2] 0 out[i3] a return bytes(out)注意一个问题不是所有 PKM 都是预乘的有些老资源可能保存的就是直通颜色。盲目对所有图片做 unpremultiply反而会把原本正确的半透明边缘变亮。正确做法是加一个命令行参数--alpha-mode auto先随机抽几张根据 RGB 与 Alpha 的比值判断是否预乘再决定对整批采用哪种策略。5.3 字节序错位导致尺寸变成 4096PKM 头部一般按大端或小端存储取决于引擎生成平台。很多项目的资源库是跨平台构建出来的Windows 上生成的 PKM 和旧版 Mac 构建出的可能字节序不同。解析时把小端解析成两个字节宽度就可能变成 4096 或 0。遇到这种问题不要先怀疑数据损坏先检查魔数和头部长度。建议头部分析代码里加一个自适应尝试先用小端读如果魔数不对再用大端读。这比让用户手动指定字节序靠谱得多。def parse_header_auto(path): for endian in [, ]: try: data read_head(path) magic, header_size, width, height, fmt, block_size struct.unpack( endian 4sIIIII, data[:24] ) if magic.startswith(bPKM): return {...} except (AssertionError, struct.error): continue raise ValueError(无法识别的 PKM 头部)5.4 同一批文件里混着多种压缩格式接手旧工程最常见的情况是目录结构看似统一实际文件来源五花八门。有的文件是 ETC1有的是 ETC2 RGB还有少量 ETC2 RGBA1。如果你在解码器里写死ETC1遇到 ETC2 文件就会解码出错输出画面花屏。而且这种错误不容易被发现因为程序不会崩溃只是看起来像显卡驱动问题。解决办法是在解析头之后立即根据format_id分流对未知格式记录日志但继续处理其他文件。我加了一个--strict参数默认宽松模式只报警不中断整批任务打开--strict后遇到未知格式立即停止方便在 CI 流程里拦截异常资源。5.5 不同工具链解码效果不一致同样的 ETC1 数据用 GPU 硬件解码和用纯软件解码结果会有微小差异。原因是不同厂商的浮点运算精度、查表实现策略不同个别像素的 RGB 值会有 1-3 的偏移。这在常规游戏里完全看不出来但如果你要拿转换结果去做资源对比、自动测试、像素级差异分析就会非常头疼。解决思路是固定工具链并记录版本。PKM2PNG 内部只用同一份 C 扩展解码器不调用第三方 GPU 换取结果确保同输入必然同输出。并且在输出目录中生成conversion_report.json包含完整的工具版本和参数避免“不同时间转换结果对比不了”的死结。6. 验证结果、提速思路和一点后续建议6.1 实测数据批处理速度与资源占用转换逻辑跑通后我拿一个真实的 UI 图集目录做了测试。这个目录里有 256 张 PKM平均尺寸 1024x1024部分 2048文件大小总和约 1.2GB。测试环境是普通办公笔记本电脑解码器使用 C 扩展结果如下模式耗时内存峰值单进程顺序执行约 85 秒约 1.8GB4 进程并行约 28 秒约 2.6GB8 进程并行约 19 秒约 4.1GB内存峰值随并发数上升很明显主要是 Pillow 保存时临时拷贝偏多。如果你处理的是 4096x4096 的大图图集建议并发数控制在 4 到 6不然很容易在低配机器上触发 OOM。正确性方面我抽取了 20 张转换结果与原工程运行时截图的对应区域逐像素对比最大误差基本在 2 以内且仅出现在颜色渐变边缘。在一张纯色价格标签和浅色文字上误差全部为 0。这说明解码链路是可靠的。6.2 提速思路把热点交给 C把结果交给缓存如果以后需要处理几千张图仅靠多进程还不够。可以做的优化有三个方向。第一把最耗时的 ETC2 块解码部分写成 C 扩展或绑定现成库性能能提升数倍。第二对目标尺寸很小的小图可以在解码时提前跳过 Alpha 通道只保留 RGB减少写入和压缩压力。第三为输入文件的修改时间和大小做缓存索引如果源 PKM 没变化下次转换直接从缓存复制 PNG速度接近文件拷贝。我自己在批量转换时还加了一个简易缓存基于源文件哈希生成输出文件若输出已存在且.meta.json中的源哈希一致则跳过解码。第一次转换后第二次运行只用了不到 3 秒。6.3 工程化建议给工具留一份“考古说明”整理完这个工具后我的最大体会是转换工具本身不难难的是让下一个维护者快速理解这批 PKM 的格式背景。所以我在工具目录里留了一个FORMAT_NOTES.md写清楚魔数、头部字段、字节序、格式 ID 对应关系、已知缺陷、验证方法。美术之后看到需要查看的 PKM可以直接按说明操作不用再抓住我问一遍。另外一个值得扩展的方向是做一个简单的 HTML 接触表把输出的所有 PNG 缩略图按原路径排列生成一个单页方便快速浏览资源内容和定位问题。这个功能实现不难但对图集类资源尤其好用。最终建议是如果旧工程里还有大量无人认领的纹理资源不要急于删除先用 PKM2PNG 全部转成 PNG 归档确认无引用后再考虑清理。转换这一步花的时间远比后续追溯历史资源时节省的时间少得多。