简介:TextureUnpacker是一款面向游戏开发者和UI设计师的TexturePacker资源解包工具,能有效解决打包后纹理图集与plist元数据难以直接查看、调试和还原的问题。该资源共34个文件、约93KB,以cpp源码和h头文件为主,辅以png图标、ui界面定义、qrc资源列表、ts翻译文件及pro工程配置,目录组织完整清晰,基本开箱即可阅读或编译。核心功能上,plistparse与domparser分别负责处理plist与XML格式的元数据,pixmapparse用于解析精灵像素与透明信息,mainwindow搭建主操作界面,savetofolderdialog实现解包结果的批量导出,另有自定义标签控件与消息框增强交互体验,整套代码从读取、解析、展示到保存形成闭环。此外,包内还提供README.md说明文档和pro.user配置文件,可帮助读者快速了解工程组织方式与构建入口。该资源已有245人浏览学习,适合对图形资源管理、TexturePacker格式解析及Qt/C++界面编程感兴趣的开发者。通过阅读源码,既能理解纹理图集的组织方式与plist数据结构的解析方法,也能掌握用Qt构建实用工具的设计思路,无论是入门参考还是工程借鉴,都能从中获得实际帮助。
1. 一张大图里藏着几十张小图:TextureUnpacker 要解决的核心问题
做 UI 改版或资源复用的人,大概率经历过这种场面:从某个旧项目里拷回一份美术资源,打开目录发现没有单张小图,只有一张 1024×1024 的大图,旁边跟着同名的 JSON 文件。大图是图集,JSON 里的 frames 字段记录着几十张小图各自的坐标。想改其中一个按钮,却找不到按钮原图。TextureUnpacker 这类工具做的事,就是把这张大图按元数据裁回几十张小图,并且把旋转、裁剪带来的变化一并还原,让资源变回能直接编辑的形态。
别以为解包就是从大图上按坐标切四块。真正做起来要处理三个东西:旋转方向、trim 偏移、不同打包工具导出的多种格式。这三件事处理不好,切出来的图要么方向错,要么位置偏,要么边缘带邻图颜色。下面按“读懂格式 → 写解包器 → 踩坑排查 → 处理非 JSON 格式 → 验证结果”的路线展开,适合游戏美术、独立开发者和做资源批处理的工程师。
2. 解包前先读懂图集元数据:坐标、旋转与 trim 的约定
图集的像素数据本身没有秘密,秘密全在元数据里。如果不先把元数据字段的含义弄清楚,写出来的裁切代码就是在猜,猜一次错一次。所以动手前,先把一份典型的图集 JSON 拆开看明白。
2.1 图集里都有什么数据:从大图到坐标清单
常见的打包工具导出的 JSON 长这样:
{ "frames": { "btn_ok.png": { "frame": {"x": 2, "y": 2, "w": 120, "h": 48}, "rotated": false, "trimmed": true, "spriteSourceSize": {"x": 0, "y": 0, "w": 120, "h": 48}, "sourceSize": {"w": 120, "h": 56} }, "icon_star.png": { "frame": {"x": 124, "y": 2, "w": 64, "h": 72}, "rotated": true, "trimmed": false, "spriteSourceSize": {"x": 0, "y": 0, "w": 64, "h": 72}, "sourceSize": {"w": 72, "h": 64} } }, "meta": { "image": "ui_atlas.png", "format": "RGBA8888", "size": {"w": 512, "h": 512} } }frames 是一个以图片名为 key 的对象,不是数组,顺序没有意义。每个条目里,frame 表示这张小图在整张图集中的包围盒,x、y 是左上角坐标,w、h 是宽高。rotated 表示打包时是否旋转了 90 度存储,trimmed 表示是否裁掉了透明边。spriteSourceSize 和 sourceSize 只有在 trimmed 为 true 时才有意义,前者给出裁切后的像素在原始画布中的偏移,后者给出原始画布的尺寸。
meta.image 是图集大图的文件名,相对 JSON 所在目录。meta.format 是像素格式,RGBA8888 表示带 alpha 通道,如果是 RGB888 或 RGB565,说明这张图集压根没有透明信息,后续解包策略要跟着变。meta.size 是大图尺寸,可以用来做坐标范围的合法性校验,防止裁出越界的矩形。
2.2 旋转与 trim:还原像素前必须处理的两个字段
旋转是图集打包时提升装箱率的常规手段。一张 120×60 的原图,旋转 90 度后变成 60×120,占用的包围盒更小,更容易塞进缝隙。存储时 frame 里的 w 和 h 就已经是旋转后的尺寸了。还原像素时,必须按 rotated 标记把图转回原始方向。这里最容易翻车:不同工具对 rotated 的语义不完全一致,有些是顺时针转 90 度存,有些是逆时针转 90 度存,解包时转回去的方向恰好相反。
trim 同样隐蔽。美术交付的源图往往带着大量透明留白,打包器会把透明边裁掉,只保留有效像素,然后记录这些像素在原始画布中的偏移。前面示例里的 btn_ok.png,sourceSize 高度是 56,frame 高度只有 48,说明上下被裁掉了 8 像素,裁掉的像素从哪里开始,由 spriteSourceSize.x 和 y 决定。还原时如果不把裁出的像素贴回一张 120×56 的透明画布,单看输出图没问题,放回界面布局时就整体错位。
2.3 左边原点、宽度高度都是整数:坐标系先对齐
大多数打包工具的图集坐标系以左上角为原点,x 向右,y 向下,这与 Pillow 的 crop 参数完全一致,写代码时不需要转换。但有些引擎导出的图集坐标以画布中心为原点,或者 y 轴向上,遇到这种数据,裁切前必须先把坐标换算到左上原点,否则输出会上下颠倒或整体偏移。
另一个容易被忽略的点是,所有坐标和尺寸都按整数处理。如果美术在导出后对图集做过缩放,元数据里的坐标不会跟着缩放,直接用坐标裁切会得到错位的图。正确的做法是优先处理原始尺寸的图集,并用 meta.size 和实际打开的大图尺寸做一致性校验,发现不一致立刻停下,不要试图在代码里按比例缩放坐标凑合。
2.4 自研脚本、现成工具还是引擎内置导出
选型没有标准答案,取决于任务频率和交付形式。如果只是临时拆几张图,找现成的 GUI 工具最快,重点确认它支持对应的元数据格式即可。如果每周都要处理一批新图集,或者要把解包步骤嵌进资源流水线,命令行脚本远比手动开软件稳定,至少不会因为漏点一个选项导致这次拆出来的图和上次不一致。
如果项目本身跑在某个引擎里,先看看引擎自带的能力。许多引擎的资源浏览器能预览图集中的单张资源,但这个能力往往只解决“看一眼”的需求,拿不出能在其他工程里直接使用的 PNG 文件。真正常用的方案,还是写一个独立于引擎的小脚本,输入图集和元数据,输出一个资源目录。这个方案还能顺便处理批量命名、格式转换、sidecar 数据导出这些引擎内置能力做不到的事。
3. 用 Python 写一个 TextureUnpacker 最小实现:JSON 解析到批量切图
语言选 Python 加 Pillow,理由很简单:Pillow 的 crop、transpose、paste 三个方法足以覆盖解包的全部像素操作,而 JSON 解析是标准库自带。下面按三步走,每一段代码都是可以直接落地的。
3.1 解析 JSON 元数据:从 meta.image 拿到图集路径
import json from pathlib import Path from PIL import Image def load_meta(meta_path): with open(meta_path, "r", encoding="utf-8") as f: return json.load(f)这里显式指定 encoding="utf-8",避免在 Windows 上因默认编码导致中文路径或中文图片名乱码。meta_path 传 JSON 文件的完整路径,返回解析后的字典。拿到字典后,图集大图的路径这样拼:
meta = load_meta("ui/ui_atlas.json") atlas_path = Path(meta_path).parent / meta["meta"]["image"] atlas = Image.open(atlas_path).convert("RGBA")meta.image 是相对路径,相对的是 JSON 所在目录,所以用 Path(meta_path).parent 拼出绝对路径。convert("RGBA") 把大图统一转成带 alpha 的像素格式,后续所有裁切都基于这个对象。如果大图本身没有 alpha,这一步会得到一个 alpha 通道全为 255 的图,不会报错,但透明区域的还原会出问题,这一点到避坑章节再展开。
3.2 切图、旋转与 trim 还原:核心函数与参数说明
def unpack_one(meta_path, out_dir="out"): meta = load_meta(meta_path) atlas_path = Path(meta_path).parent / meta["meta"]["image"] atlas = Image.open(atlas_path).convert("RGBA") Path(out_dir).mkdir(parents=True, exist_ok=True) for name, item in meta["frames"].items(): f = item["frame"] sprite = atlas.crop((f["x"], f["y"], f["x"] + f["w"], f["y"] + f["h"])) if item.get("rotated"): sprite = sprite.transpose(Image.ROTATE_90) if item.get("trimmed", False): s = item["spriteSourceSize"] source_w = item["sourceSize"]["w"] source_h = item["sourceSize"]["h"] canvas = Image.new("RGBA", (source_w, source_h), (0, 0, 0, 0)) canvas.paste(sprite, (s["x"], s["y"])) sprite = canvas sprite.save(Path(out_dir) / name)逻辑顺序是固定的:先按 frame 从大图里裁出包围盒,再处理旋转,最后处理 trim。顺序不能换,如果先还原 trim 再旋转,旋转会作用在已经垫好的画布上,导致画布尺寸和偏移全部错乱。Pillow 的 ROTATE_90 是逆时针转 90 度,适合还原“打包时顺时针转 90 度存储”的图集;如果发现输出方向不对,把这一行换成 ROTATE_270 再试,具体约定要以实际数据为准。
trim 还原这段,是用 spriteSourceSize 里的偏移值,把裁切后的有效像素贴回一张 sourceSize 大小的透明画布。paste 的坐标直接取 s["x"] 和 s["y"],这正是像素在原始画布中的绝对位置。不要自己去算中心偏移,某类 plist 格式才需要那种算法,JSON 格式直接用字段即可。
| 字段 | 在解包代码中的作用 | 常见取值 |
|---|---|---|
| frame.x / frame.y | crop 的左上角坐标,相对图集左上原点 | 整数像素 |
| frame.w / frame.h | 包围盒宽高,旋转后尺寸、不含 trim 掉的区域 | 整数像素 |
| rotated | 是否旋转 90 度存储,true 时要转回来 | true / false |
| spriteSourceSize.x / y | trim 后像素在源图中的偏移,paste 的坐标 | 整数像素 |
| sourceSize.w / h | 原始画布尺寸,决定新建 canvas 的大小 | 整数像素 |
3.3 批量解包:目录遍历、命名清洗与一跑到底
单个图集解包跑通后,下一步就是批量。图集往往会按 UI 界面拆成多个 atlas,挨个处理不现实:
import re def safe_name(name): name = name.replace("\\", "_").replace("/", "_") return re.sub(r'[\\\\/:*?"<>|]', "_", name) def unpack_all(meta_dir, out_dir="out"): for meta_path in sorted(Path(meta_dir).glob("*.json")): print(f"[unpack] {meta_path}") unpack_one(meta_path, Path(out_dir) / meta_path.stem)safe_name 处理的是图片名里的路径分隔符和 Windows 非法字符。很多图集元数据的 key 是 “icon/btn_ok.png” 这种带子目录的形式,在 macOS 和 Linux 上直接当文件名没问题,在 Windows 上会抛异常。清洗后再保存,同时输出一份清洗前后的映射表,避免下游需要原始 key 时找不到对应关系。
dir 按图集的文件名建子目录,防止多个图集里出现同名小图互相覆盖。glob 只匹配当前目录的 JSON,如果需要递归扫描子目录,把 glob(".json") 换成 rglob(".json")。整个脚本收口成一个目录参数,再接一个输出目录参数,就能塞进批处理脚本或资源流水线里一跑到底。
4. 图集解包避坑:五个高频现象的原因与排查顺序
解包踩坑记录比功能代码更值钱,下面五条是我反复遇到的高频问题,每条都按“现象 → 原因 → 解决”给出结论,照顺序排查能省下大量时间。
4.1 裁出的图边缘带着邻图的颜色
现象:单张图边缘出现一道不属于它的色边,圆弧形按钮的弧边尤其明显,颜色和旁边图集的另一张小图接近。
原因:打包工具出于纹理采样需要,经常在每张图的包围盒外预留外扩像素,图集内存放的实际像素可能比 frame 的宽高多出几像素。如果裁切 rect 用的是自己推算的包围盒,而不是元数据里的原始 frame 值,就会切到邻图区域。
解决:裁切 rect 只信 frame 字段的原始值,不要手动外扩。如果遇到确实带了出血边的数据源,裁出来先做一次透明裁剪或按 sourceSize 限制有效像素范围,再走后续逻辑。这问题属于数据约定差异,不是代码 bug,先确认数据源类型再定位。
4.2 rotated 标记在,图却转了 180 度
现象:输出图方向不对,有时旋转 90 度正确,有时要转 270 度才正确,同一个图集里表现一致但换一个图集就变。
原因:不同打包工具对 rotated 的约定有差异。有的顺时针 90 度存储,有的逆时针 90 度存储。Pillow 的 ROTATE_90 是逆时针旋转,遇到顺时针存储的数据源,就得用 ROTATE_270 反转。
解决:准备一张明显不对称的按钮图做基准样,分别用 ROTATE_90 和 ROTATE_270 试一次,确认当前数据源的约定后,把结论写进配置文件,不要每次手动改代码。如果元数据里有独立的 angle 字段,直接用数值旋转更稳,但在常见 JSON 格式里它不出现。
4.3 trim 还原后,图在画布里位置对不上
现象:单张图单独看正常,放到界面布局里却整体偏移,有时偏左有时偏上,偏移量固定。
原因:只用了 frame 坐标裁像素,忽略了 spriteSourceSize 偏移。frame 告诉的是像素在图集里的位置,spriteSourceSize 告诉的是这些像素从原始画布哪个位置裁掉的。少了后者,还原出来的图就没有了“锚点”,贴回布局自然错位。
解决:严格按第 3 章的逻辑,先裁像素、再旋转、最后把像素贴回 sourceSize 透明的画布。不要试图在文件名里手动记录偏移,标准做法就是代码里还原成完整画布,保证输出可直接用于布局。
4.4 中文文件名在 Windows 下保存失败
现象:脚本在保存图片时抛异常,提示文件名非法,或者保存后文件名变成一串乱码。
原因:图集元数据里的 key 常带完整路径,“icon/按钮.png”这种名字在 Windows 里“/”和“:”都不能出现在文件名中。JSON 本身是 UTF-8,读取没问题,但写文件系统时过不去。
解决:输出前统一清洗,把非法字符替换成下划线。需要保留原始引用时,额外输出一份 name 映射清单,后续其他工序按原 key 引用时查表还原。这点看着小,遇到整批资源报错时,浪费的时间比写清洗函数还多。
4.5 透明边缘变黑或变灰
现象:输出 PNG 后,本该透明的玻璃高光边缘变成黑色或灰色,整张图有黑底。
原因:meta.format 声明是 RGB888 或 RGB565,图集大图本身不包含 alpha 通道。透明信息在打包时被丢弃,convert("RGBA") 无中生有,它只会把没有 alpha 的像素解释成完全不透明,于是透明区域以黑色或背景色填充。
解决:解析 meta.format,遇到不含 alpha 的格式时不要硬转 RGBA,按实际格式输出 JPEG 或保留原格式。另一种情况是预乘 alpha,像素值已经乘过透明度,直接输出会得到发灰边缘,需要先做 unpremultiply 再保存。这条最花时间,建议批处理前先确认 format。
4.6 解包前的三分钟自检:从大图格式到坐标约定
开始写批处理脚本前,花三分钟按顺序确认四件事:第一,meta.format 是什么,含不含 alpha;第二,meta.image 指向的大图实际尺寸和 meta.size 是否一致;第三,rotated 标记用哪张不对称图验证过方向;第四,有没有 trimmed 记录,有的话确认 spriteSourceSize 字段齐全。这四件事确定后,剩下的就是循环代码的事,跑出来的结果基本稳。
5. 非 JSON 图集怎么办:plist 字段映射与二进制格式解析思路
JSON 不是唯一的元数据载体。早期 2D 游戏和苹果系工具链更常用 plist,一些引擎还会把坐标表直接塞进二进制文件。处理这两类格式,思路不同,但核心都是寻找等价字段。
5.1 plist 坐标是字符串:字段映射与解析代码
一套常见的 plist 图集数据如下:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>frames</key> <dict> <key>hero.png</key> <dict> <key>frame</key> <string>{{2,2},{100,80}}</string> <key>offset</key> <string>{0,0}</string> <key>rotated</key> <true/> <key>sourceColorRect</key> <string>{{0,0},{100,80}}</string> <key>sourceSize</key> <string>{120,100}</string> </dict> </dict> </dict> </plist>注意 frame 和 sourceSize 不是数组,而是字符串。解析时用正则提取数字最省事:
import plistlib import re def parse_rect(text): nums = list(map(int, re.findall(r"-?\d+", text))) if len(nums) != 4: raise ValueError(f"unexpected rect: {text}") return nums with open("atlas.plist", "rb") as f: data = plistlib.load(f) frames = data["frames"] x, y, w, h = parse_rect(frames["hero.png"]["frame"]) rotated = frames["hero.png"].get("rotated", False) source_w, source_h = parse_rect(frames["hero.png"]["sourceSize"])[2:]plistlib.load 接受文件对象,且文件要按二进制打开,这一点和 json.load 不一样。正则里的 -?\d+ 必须保留,部分 plist 的 offset 会出现负数,漏掉负号会导致偏移方向错误。这里的 parse_rect 同时适用于 frame 和 sourceSize,因为它统一提取花括号里所有的整数。
字段映射关系:JSON 的 frame 对应 plist 的 frame;rotated 对应 rotated;spriteSourceSize 对应 sourceColorRect,后者给出了有效像素在源图中的矩形,用它的 x、y 当 paste 偏移;sourceSize 对应 sourceSize。还有一个 offset 字段,它是 trim 后图片中心相对源图中心的偏移,如果数据源只有 offset 没有 sourceColorRect,偏移量可以用这个公式算:trim_x = offset_x + (source_w - frame_w) / 2,y 轴同理。推荐优先用 sourceColorRect,直白不容易错。
plist 没有 meta.image 字段时,通常约定 plist 文件与图集大图同名同目录,比如 ui_atlas.plist 对应 ui_atlas.png。写脚本时先判断同目录下有没有同名 png,再考虑用 plist 里的纹理文件名做兜底。
5.2 二进制图集:从字节里找坐标的排查思路
私有二进制格式没有统一标准,常见的结构包含头部魔数、版本号、图片数量、文件名表、坐标表和像素数据块。第一次拿到这种数据,不要指望一次写对解析器,按下面的排查步骤走。
第一步,用十六进制查看工具打开文件,搜索连续可读的 ASCII 字符串,通常文件名表比较容易暴露。第二步,观察文件名表后面紧跟的字节,如果每条文件名后面跟着固定长度的整数,那很大概率就是坐标表的入口。第三步,取一组疑似坐标,用小端 uint32 解析,看数值是否落在图集尺寸范围内;如果数值是几百万的大数,说明字节序或起点猜错了,换大端再试。
可以用下面这段探针代码快速验证:
import struct def guess_ints(data_path, offset=0, count=16, endian="<"): with open(data_path, "rb") as f: f.seek(offset) raw = f.read(count * 4) for i in range(0, len(raw) - 3, 4): value = struct.unpack_from(f"{endian}I", raw, i)[0] print(f"offset {offset + i:08x}: {value}")这段代码不是完整解析器,只是把一段字节按 4 字节无符号整数打印出来,用来人工判断坐标表起点和字节序。如果打印出的数值都落在 0 到图集尺寸之间,说明起点猜对了,可以继续解析;如果出现 0xFFFFFFFF 或超大数字,说明起点偏差了几个字节,往前或往后挪几位再试。字节序优先试小端,大部分 PC 工具导出都用小端,少数老工具用大端,头部有没有字节序标记要看具体格式。
另一个高频坑是压缩。有些引擎会把二进制图集的元数据块先用 zlib 压缩再写入文件,这时候在压缩流里搜文件名和坐标永远搜不到。先检查头部有没有压缩标记,或者看元数据区是否以不可读的高熵字节开头,疑似压缩就先解压再解析。这一步是排查里最耗时间的,但也是唯一能突破私有格式的路径。
6. 验证解包结果:整图重建对比与九宫格信息保留
6.1 整图重建对比:用像素差异确认解包正确
解包代码跑完后,最直观的验证方式不是肉眼一张张看,而是把解包结果反向合成回一张图集,再和原始图集逐像素对比。PNG 无损格式下,如果两部分逻辑完全互逆,重建图和原图应当一模一样。
from PIL import ImageChops def rebuild_atlas(meta, sprites, atlas_size): canvas = Image.new("RGBA", (atlas_size["w"], atlas_size["h"]), (0, 0, 0, 0)) for name, item in meta["frames"].items(): sprite = sprites[name] if item.get("rotated"): sprite = sprite.transpose(Image.ROTATE_270) f = item["frame"] canvas.paste(sprite, (f["x"], f["y"])) return canvas diff = ImageChops.difference(original, rebuilt) if diff.getbbox() is None: print("ok: pixel identical") else: print(f"diff at {diff.getbbox()}, max={diff.getextrema()}")重组时的旋转方向和解包时相反,解包用 ROTATE_90 还原,重组就要用 ROTATE_270 转回存储方向。如果某个条目标记了 rotated 且同时有 trim,单纯拿解包后的最终图去重组容易出错,因为 trim 还原已经改变了像素的相对位置。这种情况建议在解包时保留中间态数据,验证时直接用中间态重建,别拿最终输出图硬凑。
6.2 九宫格与 border 信息:解包别把扩展数据弄丢
很多带边框缩放的按钮,图集元数据里有 sliced 或 border 字段,记录九宫格四条边的切分位置。解包时如果只取像素,不把 border 信息单独导出,之后做 UI 会发现按钮拉伸时圆角变形、边框粗细不一致。常见做法是把每张图的 sliced 信息聚合成一份 sidecar JSON,和输出图放同一个目录。
slices = {} for name, item in meta["frames"].items(): if "sliced" in item and item["sliced"]: slices[name] = item["sliced"] with open(Path(out_dir) / "sliced.json", "w", encoding="utf-8") as f: json.dump(slices, f, indent=2, ensure_ascii=False)这份 sidecar 不是引擎运行时格式,是给美术和下游工序的交接物。在我自己处理过的一批资源里,最快的翻车不是解包代码写错,而是没先确认 format 和 rotated 约定,导致同一套图集两次解包结果不同。现在接手陌生图集,我习惯第一件事就是打印 meta.format 和验证一张不对称图的旋转方向,这两条定住了,后面基本顺。希望帮到你。
本文还有配套的精品资源,点击获取