简介:PlistDumper 是一款基于 Go 语言开发的拆图工具,主要面向游戏客户端开发者、UI 与动效制作人员,解决 TexturePacker 合图或位图字体、Spine 图集难以直接还原子图的问题。它兼容 TexturePacker 各版本 plist、多数 json 配置、fnt 位图字体文件以及 Spine 的 atlas 文件,并能按配置自动裁剪出独立图片且还原真实尺寸;程序基于 Go 实现,可跨 Windows、macOS、Linux 运行,适合集成到自动化资源流水线中。压缩包共 11 个文件,以 Go 源码(plist.go、json.go、fnt.go、spine.go 等)为主,辅以 main.go 入口、go.mod 依赖清单、README 说明、LICENSE 许可及 preview.jpg 预览图,整体仅 59KB,轻量易读。资源已有 750 人学习或下载,源码结构清晰,既可直接编译使用,也可借其拆图与解析逻辑加深对 atlas/位图字体格式的理解,便于二次开发或迁移至自有工具链。 做游戏和做前端的同学应该都绕不过TexturePacker。这玩意打包图集是真的香,几十上百张小图合成一张大图,再导出一份plist或者json元数据,运行时按坐标抠图,渲染效率蹭蹭涨。但反向操作就不那么友好了:项目清理的时候源PSD丢了,或者外包交付只给了一组图集文件,想拆出其中某张小图改改再重新打包,没有工具就只能用PS手动抠。PlistDumper就是把这件事自动化——读取plist、json、fnt、atlas四种格式的描述信息,从整张纹理大图里把每个子图裁剪导出,还原成可以重新编辑的散图。
工具本身用Python写的,解析端把四种格式统一转换成内部帧结构,导出端用Pillow完成裁切、旋转、坐标还原,整体逻辑不算复杂,但细节坑不少。这篇文章我从需求背景、格式结构、实现原理、实际操作到问题排查完整讲一遍,适合正在做资源工具链、或者经常需要处理TexturePacker素材的开发者参考,看完可以直接照着搓一个自己用的拆图工具。
1. 为什么需要拆图工具:一个被忽略的开发痛点
1.1 图集打包的原理与代价
TexturePacker的核心工作方式其实很简单:把很多尺寸不一的小图放进一张大纹理里,尽量紧凑排列,然后记录每个小图在大图中的矩形坐标。运行时渲染时只需绑定一次纹理,再用UV定位,GPU不用来回切换纹理单元,对于UI、动画、粒子这类小图密集的场景提升非常明显。代价就是“格式二义性”——原本各自独立的图片文件信息全部浓缩到一个描述文件里,散图不复存在。
一旦你拿到的资源只有图集图片和元数据,再想回到原始散图状态就麻烦。这个“有损逆向”的难度比想象中大,因为TexturePacker导出的元数据里不仅记录了位置信息,还包含了旋转、透明裁剪偏移等状态,这些字段单独看都能懂,组合在一起就是个容易被忽略的小陷阱。
1.2 实际工作中哪些场景需要逆向拆图
我遇到的情况不少。最典型的是接手老项目,原工程文件都还在,但美术源文件丢了,UI突然要改其中一个小图标,又不能用运行时缩放糊弄,只能从图集里把原图还原出来,放到Photoshop里重新调整。第二个常见场景是分析竞品或开源项目的资源结构,把图集拆开后按文件名归类,能快速理解这游戏有哪些角色、哪些UI组件,对做技术调研特别有用。还有一个场景是自动化批处理,比如要对图集所有子图统一做抗锯齿、缩放或者格式转换,拆开处理再重新打包是最可控的方式。
1.3 PlistDumper要解决的“最后一公里”
其实网上有各种散落的小工具能做图集拆分,但多数只支持plist,有的还要付费或者需要安装GUI。我自己需要的是一个快速、可集成进脚本流程的命令行工具:输入一个元数据文件,自动找到同一目录下的图集图片,批量导出所有帧。于是PlistDumper就诞生了。它不追求华丽的界面,核心就一个目标:命令敲下去,指定格式文件和图集,几秒钟内得到全部散图,上下文信息完整保留。
2. 四种格式解剖:先把数据读懂再动手
2.1 plist:XML外壳加嵌套字典
plist本质上是XML。TexturePacker导出的plist根节点是dict,里面两个顶层key:frames和metadata。frames下面每个key是一个子图文件名,对应的value则是包含frame、offset、rotated、sourceColorRect、sourceSize等字段的字典。frame的格式类似{{1,2},{100,200}},第一个花括号是坐标,第二个是宽高,坐标原点在左上角。
用Python解析时,直接plistlib.load读完就是嵌套dict,非常方便。但要注意历史项目里plist可能是format=1的旧版,字段会少offset和sourceColorRect,按新版写法去读会报KeyError。我的做法是先读metadata.format判断版本,再动态兼容,这样2009年的老项目和今年的新工程都能处理。
2.2 json:同一套数据换个马甲
TexturePacker的json导出其实是同一套数据的另一种表现。frames对象里每个key对应子图名,value里的frame是{x, y, w, h}结构,meta里记录image文件名和size。解析思路和plist一一对应,甚至更简单,因为json本身没有plist那种字符串嵌套格式,直接dict取字段就行。
那为什么不直接用plist而是用json?实际工程里,Web项目和部分游戏引擎对json支持更好,不依赖原生xml解析,运行时的反序列化代价更小。而且json可读性就好很多,出问题的时候用文本编辑器打开瞄一眼就知道结构对不对。PlistDumper对两种格式做了同样的处理路径,解析层互换,导出层完全复用。
2.3 fnt:一行一字符,简单但字段多
fnt是位图字体描述格式,看起来像是INI和CSV的混合体。第一行info描述字体名和字号,第二行common记录纹理尺寸、行高和baseline,page行指定纹理图片的文件名,之后是大量char行,每一行对应一个字符,记录了字符的id以及x、y、width、height、xoffset、yoffset、xadvance等参数。有些fnt后面还有kerning行,那是字距调整信息,导出图片时用不上。
PlistDumper对fnt的处理是逐行拆分,遇到char开头的行就解析出整数参数,再用同样方式裁图。注意fnt的y坐标同样是左上角原点,部分老软件用的是左下角,但TexturePacker导出的fnt是标准的左上角,这一点我专门验证过。
2.4 atlas:libgdx系的缩进轻量格式
atlas是libgdx纹理图集的标准格式,也是TexturePacker的一个内置导出选项。它的结构靠缩进表达:第一行是大图文件名,接着是size、format、filter、repeat等全局属性,之后每帧是一个缩进块,块内包含rotate、xy、size、orig、offset、index这些键值对。rotate表示原图是否被旋转过,index用于同名多帧的索引区分。
解析时最怕缩进混乱。不同工具生成的atlas有的用Tab缩进,有的用两个空格,有的用四个空格。我的做法是不依赖固定缩进层级,而是把“某一行key后面跟着的缩进内容”理解为帧的属性块,这样即使混用Tab和空格,也能正确拆出帧边界。
3. 核心实现解析:统一模型、裁剪与旋转还原
3.1 用统一的帧模型屏蔽格式差异
为了让四种格式共享一套导出逻辑,我在PlistDumper里定义了一个Frame数据类,核心字段包括:name(子图名或字符id)、frame_rect(图集中的矩形)、rotated(是否经过旋转)、source_size(原始素材尺寸)、sprite_offset(透明裁剪造成的偏移)、trimmed(是否裁过透明边)。解析层的任务就是从各自格式中提取出这几个值,其余字段全部丢弃。
这个抽象让后续裁图代码写得非常顺,完全不需要关心输入来自哪种格式。后面如果有人想支持新的图集格式,比如Unity的SpriteAtlas或者Godot的纹理图集,只要新增一个解析函数,把数据映射到Frame模型就行,导出代码一行都不用动。
3.2 裁剪逻辑与旋转还原:方向千万别搞反
从大图裁剪小图的核心操作就是image.crop((x, y, x+w, y+h)),这一步只要坐标读对了基本不会出错。但TexturePacker的旋转标记是个经典坑。为了最大化利用矩形空间,TexturePacker会把部分图片旋转90度后再放入图集。那么你按frame坐标裁出来的图像实际上是“横着”的,需要再逆时针旋转90度才能还原成原图。
from PIL import Image def extract_tile(image: Image.Image, frame) -> Image.Image: left, top, width, height = frame.frame_rect box = (left, top, left + width, top + height) tile = image.crop(box) if frame.rotated: # Pillow 的 rotate(90) 是逆时针旋转 90 度 tile = tile.rotate(90, expand=True) return tile如果发现导出图片内容方向不对,大概率是这里的语义搞反了,把90改成-90再试。实测下来,TexturePacker在plist和atlas里用true表示顺时针旋转90度存放,所以还原时需要逆时针转回来。json格式里的rotated字段表达的是同一个意思。
3.3 处理trimmed偏移:光裁frame还不够
另一个容易漏的坑是透明裁剪。TexturePacker默认会裁掉子图周围完全透明的像素,然后在元数据里用offset和sourceColorRect记录原始位置。如果你的图片素材本身存在空白边距,不处理这个偏移直接裁,导出的图会比原始素材“缩水”一圈。
处理方式是在裁完frame之后,创建一个source_size尺寸的透明画布,把裁取的图像按offset放回正确位置:
def restore_trimmed(tile: Image.Image, frame) -> Image.Image: if not frame.trimmed: return tile canvas = Image.new("RGBA", frame.source_size, (0, 0, 0, 0)) canvas.paste(tile, (frame.sprite_offset[0], frame.sprite_offset[1]), tile) return canvas这段代码的输出就是一帧和原始素材像素级一致的结果,包括透明边框。如果发现还原后图片内容“飘”在画布正中间但位置不对,要去检查offset是不是按中心点算的,需要在解析时转成左上角偏移。
3.4 导出命名与文件组织策略
最后一环是文件落地。默认情况下PlistDumper将所有帧输出到同一个目录,名字就用frames里的key。但遇到两个图集都包含同名资源时,导出文件会互相覆盖,这是批量处理时最头疼的问题。所以工具里加了一个--prefix参数,可以在导出文件名前加上图集名作前缀,类似pack1_icon.png和pack2_icon.png。
fnt格式的特殊之处在于帧名不是业务名而是字符id,比如char_65.png,但如果你后续要重新拼装位图字体,看id反而直观。另外建议输出时保留一张“原图集缩略图”以备对比,这在排查导出内容对不上的时候很有用。
4. 实操演示:命令行拆包的完整流程
4.1 环境准备与依赖安装
PlistDumper只依赖Python 3.9以上的标准库加Pillow,安装非常简单:
pip install Pillow把项目里的plist_dumper.py和frame_model.py放到同一个目录即可,不需要额外配置文件。单文件脚本的好处是拷到哪都能跑,在美术同事的Windows电脑上也不会因为环境差异跑不起来。
4.2 命令行参数设计与自动找图
命令尽量简短,同时能自适应格式。核心参数就三个:-f/--file指定元数据文件,-i/--image指定图集图片,-o/--output指定输出目录。另外加--prefix控制文件名前缀,--verbose打印每一帧的解析信息。
python plist_dumper.py -f icons.plist -o exported/如果不指定-i,工具会在元数据文件的同目录去找图片——这个逻辑是从plist的metadata里读textureFileName,json的meta里读image,atlas第一行直接就是图片名。如果没有同名图片,再报错提示手动指定。
4.3 真实图集拆包演示
以一份Cocos2d-x项目中最常见的plist为例:我有一张icons.plist和icons.png,里面大概有40个图标,其中12个是旋转过的,大部分都有透明边框。执行命令后,工具依次完成四件事:读取plist、拼接图片路径、遍历frames、逐帧裁切。
python plist_dumper.py -f icons.plist -o exported/ --verbose输出内容类似:
[INFO] 加载元数据: icons.plist [INFO] 图集图片: icons.png (1024x1024) [INFO] 共发现 40 帧 [INFO] 导出 frame_001.png: xy=(12,34) size=128x128 rotated=True [INFO] 导出 frame_002.png: xy=(150,20) size=64x64 trimmed=True所有帧导出结束大约耗时200毫秒,速度足够快。用--verbose能清楚看到每一帧的原始坐标和旋转状态,方便跟TexturePacker里的原图对照。
4.4 批量处理多个图集的脚本写法
如果手上有一整个目录的图集,直接写个循环脚本,让它们按顺序导出到不同子目录:
for f in ./atlases/*.json; do name=$(basename "$f" .json) python plist_dumper.py -f "$f" -o "out/$name" done这就是做成CLI工具的最大价值。GUI工具遇到批量任务只能一个个点,脚本可以一夜之间导出几百个图集,而且能接进CI流水线,资源更新后自动重新拆包,整个过程不需要人工介入。
5. 问题排查实录:拆图过程中常见的五个坑
5.1 元数据格式不兼容导致解析失败
很多TexturePacker老版本或第三方工具生成的json并不严格符合官方导出的schema。比如frame字段有的是字符串{{x,y},{w,h}},有的是对象{x: 1, y: 2, w: 3, h: 4}。我的做法是写一个normalize函数,不管哪种类型都转成(x, y, w, h)元组,遇到不兼容的类型就抛出带字段名的明确报错,方便快速定位是哪个文件的问题。
| 格式 | frame字段类型 | 解析方式 |
|---|---|---|
| plist | 字符串{{x,y},{w,h}} | 正则提取数字 |
| json | 对象{x,y,w,h} | 直接读字段 |
| fnt | 多个整数列 | 按列索引取值 |
| atlas | 独立键值对 | 逐行解析并转换 |
5.2 导出图片方向不对,怎么排查
如果发现裁出来的所有图片都是旋转过的方向不对,大概率是rotated处理方向反了。把tile.rotate(90)改成tile.rotate(-90)再试。如果只有个别帧方向不对,检查该帧的rotated标记在元数据里是不是有不同写法,比如有的导出工具用true和false,有的用1和0,还有的干脆不写这个字段。解析时统一转成布尔值可以解决一半问题。
5.3 修剪还原后图片位置对不齐
对不齐基本都是offset的语义理解偏差。有些格式里offset是相对frame中心到sourceColorRect中心的偏移,而不是简单的左上角偏移。如果直接拿offset当左上角坐标去粘贴,还原出来的图跟原图相比会有明显位移。正确做法是用sourceSize.width/2 - spriteSourceSize.width/2 - spriteSourceSize.x这类公式把中心偏移换算成左上角偏移,或者干脆用sourceColorRect里的坐标来对齐。
5.4 atlas缩进混乱导致的解析错位
一些第三方导出的atlas文件缩进不够规范,有的用Tab有的用空格,混用之后按固定缩进层级解析很容易错位。我的处理方式是:识别一个帧块的起点时不依赖缩进深度,而是看“某个key后面是否跟着连续的缩进行形成属性块”,这样能抗住混用空格引起的解析问题。实测对TexturePacker官方格式和LibGDX运行时导出的atlas都适用。
5.5 输出PNG出现黑边或半透明异常
导出PNG后边缘有黑边或白边,这往往不是拆图工具的问题,而是图集本身用了预乘Alpha或者非RGBA格式。PlistDumper默认输出RGBA,不做额外的混合处理。如果你的项目必须保留预乘信息,可以在导出时加上--premultiplied选项,保持纹理数据和图集一致,避免二次处理时颜色偏移。
踩过这么多坑之后,我的体会是拆图工具的核心难点不在“裁”那一步,而在于把元数据里的语义统一清楚——旋转方向、坐标原点、offset参考点,每个都是一不小心就翻车的细节。工具本身是为了解决实际需求而写的,但它把这些坑沉淀了下来,后续团队里谁遇到类似问题,直接跑一下PlistDumper就能解决,不用再对着TexturePacker的导出文档从零研究。最后再分享一个判断旋转方向的小技巧:裁剪出一帧后对比原图集里相邻帧的纹理走向,如果肉眼能看出横向内容变纵向了,那就把旋转参数反一下,这个经验比任何文档都直观。
本文还有配套的精品资源,点击获取