
简介TextureUnpacker是一款面向游戏开发者、UI设计师及图形资源管理程序员的轻量工具用于解析TexturePacker生成的图集资源读取plist中的精灵位置、尺寸与裁剪参数并支持预览和导出到指定文件夹。压缩包共34个文件总量仅93KB包含8个cpp与7个h核心源码、2个Qt界面文件以及png预览图和工程配置代码体量小、模块划分清晰。资源对plistparse、pixmapparse、domparser等解析环节一并实现覆盖元数据读取、图像数据解析、主窗口交互和保存对话框等完整流程同时又自定义QLabel控件与消息框扩展来展示解析结果可直接作为Qt C图形文件解析的学习样例。该项目已有245人学习浏览适合刚接触纹理图集打包的入门者理解plist与图片的对应关系也适合中高级程序员参考工程结构、DOM解析及Qt界面定制来完成类似解析工具。1. TextureUnpacker把打包纹理还原成原始贴图的拆包工具拿到一个 TextureUnpacker 的拆包需求通常意味着你面对的不是一张干净的 PNG而是被引擎或工具链焊死的黑匣子——游戏资源里常见的精灵图集、DDS 压缩贴图、带旋转帧的合图单靠看图软件根本分不开。TextureUnpacker 的核心工作就两件事从图集元数据里解析出每个子图的矩形坐标再把这些坐标按旋转和裁剪信息还原成独立贴图顺带处理 GPU 压缩纹理的解压和通道重排。适合谁用做 Mod 提取、资源复用的游戏开发者接手祖传资源包的客户端程序员以及做资产自动化管线的工具链工程师。它不解决为什么引擎渲染出来是黑的这类运行时问题但能让你在十分钟内拿到可编辑的原始贴图。2. 识别资源格式文件签名、元数据与解包前置条件动手拆包之前第一件事不是找算法而是确认手里的文件到底是什么容器、什么压缩格式、什么坐标系统。这一步搞错后面所有裁剪代码都是在错误的地基上盖楼。2.1 文件签名与格式识别先读十六进制再谈解包绝大多数纹理容器格式都在文件头部写了几字节的魔数Magic Number读出来就能确定容器类型。常见的几种签名我用十六进制查看器验证过直接列在这里容器格式头部签名十六进制说明DDS44 44 53 20即 DDS DirectDraw Surface老牌 DirectX 纹理容器PVR50 56 52 03即 PVR\x03PowerVR 纹理移动端常见KTXAB 4B 54 58 20 32 30即 «KTX 20Khronos 标准容器跨平台PNG89 50 4E 47 0D 0A 1A 0AWeb 图集最常见的落盘格式识别逻辑不复杂读文件前 8 字节做一个签名匹配即可import struct def sniff_texture_format(path: str) - str: with open(path, rb) as f: head f.read(8) if head[:4] bDDS : return dds if head[:3] bPVR and head[3] 0x03: return pvr if head[:7] b\xabKTX 20: return ktx2 if head[:8] b\x89PNG\r\n\x1a\n: return png return unknown这段代码用前 8 字节做快速分流返回的是容器类型而非像素格式。注意 KTX2 的签名带版本号20老版 KTX1 的签名是AB 4B 54 58 20 31 31如果你只匹配 KTX2碰到老资源会漏判。我一般会两种都匹配返回时带上版本后续解压路径会不一样。注意容器签名只能告诉你这是什么壳不能告诉你里面是什么压缩格式。同一个 DDS 文件内部可能是 BC1、BC3、BC7 或未压缩的 ARGB32必须继续读DDS_HEADER.dwFourCC或 KTX2 的vkFormat字段才能确定。2.2 元数据解析图集 JSON/plist 的读取如果资源是图集Texture Atlas而非单张压缩纹理你还会带一个同名的 JSON 或 plist 文件。以 TexturePacker 导出的 JSON 为例结构大致如下{ frames: { hero_idle_01: { frame: {x: 2, y: 2, w: 128, h: 128}, rotated: false, trimmed: false, spriteSourceSize: {x: 0, y: 0, w: 128, h: 128}, sourceSize: {w: 128, h: 128} }, hero_idle_02: { frame: {x: 132, y: 64, w: 96, h: 128}, rotated: true, trimmed: true, spriteSourceSize: {x: 16, y: 8, w: 110, h: 140}, sourceSize: {w: 128, h: 140} } }, meta: { app: TexturePacker, image: atlas.png, scale: 1.0, size: {w: 512, h: 512} } }解析这段 JSON 只需要标准库import json def load_atlas_meta(meta_path: str) - dict: with open(meta_path, r, encodingutf-8) as f: data json.load(f) frames data[frames] meta data[meta] print(f图集尺寸: {meta[size][w]}x{meta[size][h]}, 缩放: {meta[scale]}) return frames, meta这个函数返回帧字典和图集元信息。scale字段容易被忽略——如果你把图集导出时开了 0.5 倍缩放那么frame里的坐标是缩放后的值裁剪后必须按1/scale放大才能还原原始像素密度。plist 格式本质是 XML 嵌套字典解析思路一致只是取值路径从frames.key.frame换成了递归 dict 的{x,y,width,height}四元组。注意Cocos2d 系 plist 的frame字段格式是{{x,y},{w,h}}字符串不是独立数值。用之前先按格式解析别直接把字符串当坐标用。3. 核心拆包流程从图集提取子纹理的完整实现元数据解析完成之后真正的拆包逻辑只需要三步按矩形裁剪、处理旋转、还原修剪后的尺寸。这三步的顺序不能乱。3.1 拆包主流程遍历帧数据、按矩形裁剪裁剪本身在 Pillow 里就是一行crop但要注意画布坐标和元数据坐标的对应关系。绝大多数图集元数据的 y 轴是自上而下而部分引擎如 OpenGL 的纹理坐标系是自下而上——如果你的输出图整体上下颠倒优先检查这个。from PIL import Image from pathlib import Path def split_atlas(atlas_path: str, frames: dict, out_dir: str) - list: atlas Image.open(atlas_path) out_dir Path(out_dir) out_dir.mkdir(parentsTrue, exist_okTrue) saved [] for name, frame_info in frames.items(): frame frame_info[frame] rotated frame_info.get(rotated, False) trimmed frame_info.get(trimmed, False) box (frame[x], frame[y], frame[x] frame[w], frame[y] frame[h]) sprite atlas.crop(box) if rotated: # 图集里存的是旋转 90 度后的图还原时要逆时针转回去 sprite sprite.transpose(Image.ROTATE_90) if trimmed: # 原图有透明边被裁掉需要贴回原始画布 canvas Image.new(RGBA, (frame_info[sourceSize][w], frame_info[sourceSize][h]), (0, 0, 0, 0)) source_rect frame_info[spriteSourceSize] canvas.paste(sprite, (source_rect[x], source_rect[y])) sprite canvas out_path out_dir / f{name}.png sprite.save(out_path) saved.append(str(out_path)) return saved这段代码的四个关键参数是frame、rotated、trimmed、spriteSourceSize。frame决定从图集哪个矩形区域取像素rotated表示该帧在图集里被旋转了 90 度需要转回来trimmed表示导出时裁掉了透明边缘需要按spriteSourceSize提供的偏移把像素贴回原尺寸画布。save时默认 PNG如果原图集带 Alpha 通道RGBA 模式不会丢失透明度。3.2 旋转帧与修剪后的尺寸还原两个最容易错位的细节上一节代码里我把旋转和修剪放在裁剪之后这个顺序是经验之谈。先旋转再贴画布和先贴画布再旋转结果完全不同。举例一个 96x128 的帧旋转后实际是 128x96如果先按原矩形贴回sourceSize画布再旋转多出来的空白会出现在错误的位置。def restore_rotated_trimmed(sprite: Image.Image, frame_info: dict) - Image.Image: # 先旋转还原 if frame_info.get(rotated, False): sprite sprite.transpose(Image.ROTATE_90) # 再贴回原始尺寸画布 if frame_info.get(trimmed, False): canvas Image.new(RGBA, tuple(frame_info[sourceSize].values()), (0, 0, 0, 0)) src frame_info[spriteSourceSize] canvas.paste(sprite, (src[x], src[y])) sprite canvas return sprite这里tuple(frame_info[sourceSize].values())取的是[w, h]注意顺序。spriteSourceSize里的x、y是原图上可见像素区域的左上角坐标不是相对偏移含义是可见区域在原画布中的位置。如果你把x、y当成偏移量直接加到裁剪坐标上输出图会整张平移肉眼看不出来但像素对比时会全错。提示Pillow 的transpose(Image.ROTATE_90)是逆时针旋转。TexturePacker 里rotated: true表示顺时针旋转 90 度存入图集所以还原时必须逆时针转。方向搞反的后果是输出图左右镜像翻转的。4. GPU 压缩纹理解压BC/PVRTC/ASTC 的转换与通道对齐图集拆包解决的是合图分图问题但很多资源包直接放单独压缩纹理——DDS、PVR、KTX2 这类格式无法用 Pillow 打开必须先解压到 RGBA 再做后续处理。这一章解决的是压缩格式判定、解压路径选择和像素通道对齐。4.1 压缩格式判定与解压路径FourCC 与 vkFormat 的映射DDS 文件的压缩格式藏在DDS_HEADER的dwFourCC字段里常见取值与 BC 格式的对应关系如下FourCC 字符串对应压缩格式解压后通道DXT1BC1RGB 1-bit AlphaDXT3BC2RGB 4-bit AlphaDXT5BC3RGB 8-bit Alpha插值ATI2/BC5BC5双通道法线贴图专用BC7BC7RGBA高质量压缩判定逻辑是读偏移 84 字节处的 FourCC 字段def get_dds_fourcc(path: str) - str: with open(path, rb) as f: f.seek(84) # DDS_HEADER.dwFourCC 的偏移量 fourcc f.read(4).decode(ascii, errorsignore) return fourcc拿到 FourCC 之后决定用哪条解压路径。我的习惯是优先找现成的库不自己实现 BC 解压算法—— BC1 的解压核心是 4x4 块两个 RGB565 端点颜色加 2-bit 索引插值看起来不难但 BC3 的 Alpha 用了 8-bit 插值BC7 的模式选择多达 8 种自己写纯属给自己挖坑。常见做法是调用bcdecC 库带 Python 绑定或用 ImageMagick 的identify探测后转 PNGmagick input.dds -define dds:preserve-alphatrue output.pngImageMagick 内部用 libtex 解析 DDSdds:preserve-alpha这个 define 是必须的否则部分版本会强行丢 Alpha。KTX2 的解压没有通用捷径官方推荐toktxktx2check工具链或者直接用basisu转成 PNG。ASTC 则建议用 ARM 官方的astcenc命令行astcenc -dh input.astc output.png-dh表示解码为半浮点-d解码为全浮点。ASTC 是块状压缩解码后可能出现块边界轻微色差这是格式特性不是工具 bug。4.2 像素格式转换与通道对齐BGRA、Premultiplied Alpha 与 Y 翻转解压到 RGBA 之后还有三个通道层面的坑。第一个是 BGRA 与 RGBA 的混淆——DDS 未压缩格式的像素布局经常是 B、G、R、APNG 是 R、G、B、A直接保存会让红蓝互换。第二个是 Premultiplied Alpha——引擎为了性能把 RGB 乘以 Alpha 预乘了导出后不还原的话贴到暗背景上看边缘有黑圈。第三个是上下翻转——DirectX 纹理的扫描线顺序和 PNG 相反需要做垂直翻转。def fix_channel_order(data: bytes, width: int, height: int, source_bgra: bool True, premultiplied: bool False, flip_y: bool False) - Image.Image: # 通道重排: BGRA - RGBA if source_bgra: data data[2:3] data[1:2] data[0:1] data[3:4] # 示意实际需整块重排 img Image.frombytes(RGBA, (width, height), data) if premultiplied: # 反预乘: 从 RGB 里把 Alpha 除回来 r, g, b, a img.split() r r.point(lambda v, aa: int(v * 255 / max(a.getpixel((0, 0)), 1))) # 实际按像素遍历 img Image.merge(RGBA, (r, g, b, a)) if flip_y: img img.transpose(Image.FLIP_TOP_BOTTOM) return img这段代码只展示了逻辑骨架实际工程里通道重排不要用切片拼接效率太低正确做法是用numpy重排整块数组或直接用 Pillow 的Image.frombytes配合rawdecoder 指定通道顺序。反预乘的除法要注意除零保护——Alpha 为 0 的像素直接输出黑色即可。flip_y参数开着还是关着取决于源纹理的坐标约定OpenGL 系的 DDS 通常已经翻转过DirectX 系的需要手动翻。注意PowerVR 的 PVR 文件头里有一个PVRHeader.flags字段其中 bit 2 表示纹理是否已经垂直翻转。解压后先读这个 flag 再决定翻不翻别一刀切。5. 避坑记录拆包时最容易翻车的 4 个问题拆包工具链写起来不难但跑起来总在奇怪的地方翻车。以下四个坑是我在对接不同引擎资源时真实踩过的按现象 → 原因 → 解决记录。5.1 帧旋转后裁出来是斜的现象拆出来的图内容整体旋转了 90 度或者内容是对的但长宽和元数据不一致。原因TexturePacker 的rotated: true表示该帧在合图时被顺时针旋转了 90 度以优化矩形排布还原时必须逆时针旋转。但 Cocos2d 的 plist 里部分版本把这个字段命名为rotated部分版本在帧数据里直接用了width/height交换后的矩形——如果你只认字段不认版本方向就会错。解决先按frame元数据裁剪原矩形再做一次ROTATE_90逆时针然后对比frame.w和frame.h与sourceSize的关系。如果裁剪后的宽高和源尺寸相同基本可以判断没有旋转如果宽高互换必须旋转。5.2 Alpha 通道全黑RGB 和 Alpha 分成两个纹理现象拆出来的图彩色内容正常但 Alpha 通道全黑整张图变成剪影。原因部分移动端引擎尤其是用 ETC1 的不支持 RGBA 压缩于是把 RGB 和 Alpha 分开存成两张纹理运行时再合成。单独解包其中一张拿到当然只有 RGB 或只有 Alpha。解决拆包前先看资源目录里是否有同名的_alpha、_a后缀文件。如果有把两张图按像素合并——用 Alpha 纹理的亮度值作为最终 Alpha 通道def merge_rgb_alpha(rgb_img: Image.Image, alpha_img: Image.Image) - Image.Image: rgb rgb_img.convert(RGBA) alpha alpha_img.convert(L) # 灰度 r, g, b, _ rgb.split() return Image.merge(RGBA, (r, g, b, alpha))合并后还需要检查 Alpha 纹理是否带 Premultiplied 标记带的话再做一次反预乘处理。5.3 Premultiplied Alpha 导致边缘发灰发黑现象输出图在编辑器里看正常但贴到深色背景上边缘有一圈灰边或黑边。原因引擎导出纹理时做了预乘 AlphaRGB 值乘以 Alpha图片查看器会忽略这一层直接用会得到边缘色差。DDS 头里没有标准字段标记是否预乘但文件名含_premul或元数据里有premultipliedAlpha标志的是高危对象。解决检测到该标志后执行反预乘def unpremultiply(img: Image.Image) - Image.Image: r, g, b, a img.split() r r.point(lambda v: int(255 * v / max(a_val, 0.01))) g g.point(lambda v: int(255 * v / max(a_val, 0.01))) b b.point(lambda v: int(255 * v / max(a_val, 0.01))) return Image.merge(RGBA, (r, g, b, a))这里的除法要按像素取对应 Alpha 值实际工程里用 numpy 向量化实现上面代码只是示意。除零保护用 0.01 而不是 1避免纯黑像素被拉亮。5.4 DDS 颜色发蓝偏色与上下颠倒现象解压出来的 DDS 图红色和蓝色完全互换并且图像上下颠倒。原因两个问题叠加。DDS 未压缩格式里像素是 BGRA 顺序PNG 是 RGBA同时 DDS 的扫描线存储顺序从底部开始直接转换必然颠倒。解决解压后先做通道重排BGRA→RGBA再做垂直翻转。顺序不能反——通道重排不改变扫描线顺序翻转也不改变通道顺序两个操作正交先后无所谓但归并到代码里必须都执行漏一个就出上面的现象。6. 批处理与自动化验证让拆包结果直接进资产管线单张图拆完容易批量拆包才是把工具链落地的关键。我的做法是写一个批处理入口扫描目录下所有图集和压缩纹理输出一个manifest.json记录每个子图的来源、裁剪矩形、是否旋转、是否预乘方便后续资产管线直接消费这个清单。def batch_unpack(root_dir: str, out_dir: str): results [] for meta_path in Path(root_dir).rglob(*.json): atlas_path meta_path.with_name(meta_path.stem .png) if not atlas_path.exists(): continue frames, meta load_atlas_meta(str(meta_path)) saved split_atlas(str(atlas_path), frames, out_dir) results.extend({ name: Path(p).stem, path: p, from: str(meta_path), time: os.path.getmtime(p), } for p in saved) with open(Path(out_dir) / manifest.json, w) as f: json.dump(results, f, ensure_asciiFalse, indent2)manifest.json生成后我会随机抽 10% 的子图做像素级校对——拿原图集的帧矩形区域做一次局部裁剪和拆包输出做逐像素 diff允许的误差是 0。这个验证成本很低但价值极高它能把旋转方向、修剪偏移、通道顺序三类错误一次性暴露出来。从那以后我每次跑完拆包都强制做一遍清单生成 像素抽检这套流程宁可多花两分钟也绝不让带病结果混进资产库。如果你也遇到这些坑希望这篇笔记能帮你把拆包管线理顺少走几步弯路。本文还有配套的精品资源点击获取