简介:面向游戏开发与UI设计人员的Plist合图分割工具,专门把TexturePackr等生成的纹理合图及配套plist元数据还原为原始小图,解决素材单独修改、复用与局部更新难题。工具基于Qt框架开发,集成FreeImage图像处理库,可读取常见图片格式并按plist记录的位置、大小与旋转信息精确裁剪导出。压缩包共21个文件,含SplitTool.exe主程序、AesEncDecTool.exe加解密辅助工具、16个运行所需的dll(如Qt核心库、FreeImage、ICU组件等),以及aes.c/h源码,整体18.8MB,免安装、便于本地直接运行。已有291人学习下载。使用该工具可批量拆分合图并保留原始命名,同时提供AES加解密源码供二次开发参考,对需要维护大量图集资源的开发者来说,是高效实用的图像资源管理方案。
1. 为什么需要自己写一个Plist合图分割成小图工具
做游戏资源还原或者UI资源拆包的同学,应该都撞上过这种需求:手头只有一张Plist合图和一份坐标文件,原始小图却不知道丢到哪里去了。所谓Plist合图分割成小图工具,就是顺着Plist里记录的位置信息,把大图上一个个小图裁剪出来、还原成可用PNG的脚本或软件。它能解决资源交接、逆向还原、批量导出的问题,适合独立开发者、工具链工程师和美术同学。它的价值不在于“切图”这个动作本身,而在于把一份没有原图的资产变成可继续编辑的素材;理解了它的原理,你就不会再被这类资产生死问题卡住。
2. 先搞懂Plist合图的结构:frame、rotated、offset各是什么
2.1 合图的本质:一张大图和一份坐标账本
合图(Texture Atlas / Sprite Sheet)是现代游戏引擎和UI编辑器里非常常见的一种资源组织方式。把小图一张张拼进一张大图,渲染时只需要一次性绑定纹理、一次性提交绘制,就能大幅减少绘制调用;同时减少小图文件数量也能降低磁盘加载压力。PDF、动画序列帧、UI皮肤,都会用这种方式打包。
Plist就是那本「坐标账本」。它记录了每一个小图在大图中的位置、原始尺寸、是否旋转、偏移多少等信息。比如一张 hero.png 由 icon_001.png、icon_002.png 等几十张小图拼成,Plist里就会有一条条对应记录。合图工具负责把散图拼成一张大图并生成Plist,而我们的分割工具做的就是完全相反的事:读Plist、算坐标、按坐标裁剪。
理解这个结构是写工具的第一步。把Plist当黑匣子处理,遇到特殊格式或者边缘情况就会翻车;跟着字段走一遍,切图脚本基本就是「查表 + 裁剪 + 微调」的组合。
2.2 frame字段里藏着坐标、旋转和透明裁剪信息
一份典型的Plist,不管用什么工具生成,核心内容都差不多:顶层是一个dict,里面有一个frames字段,frames下每个条目代表一张小图。常见字段如下:
| 字段名 | 作用 | 说明 |
|---|---|---|
| frame | 小图在大图中的矩形区域 | 通常写成 {{x,y},{width,height}},也可能写成嵌套dict |
| rotated | 打包时是否旋转了90度 | 很多合图工具为了节省空间会旋转细长图 |
| sourceSize | 原始小图的画布尺寸 | 合图时可能裁掉了透明边缘,这个字段保留原始宽高 |
| offset | 裁剪后内容相对中心的偏移 | 用于把裁剪内容放回原始画布的正确位置 |
| scaleFactor | 资源倍率 | Retina或高清资源常见就是2.0或3.0 |
| sourceColorRect | 部分工具会记录实际内容区域 | 和offset配合,也是还原原图的辅助信息 |
单独看frame字段最容易出问题。它代表的是「在合图大图上的一块矩形区域」,按这个区域裁剪出来的图,直接保存就是一张可用小图——但这里有一个前提:要么这张小图在打包时没有被裁掉透明边,要么你不在乎还原原始画布尺寸。如果打包时美术在源文件里留了大量透明边距,合图工具通常会把这些透明区域裁掉以节省空间,此时裁剪出来的图尺寸会比原始小图小。要还原原始画布,就得用sourceSize和offset重建背景画布。
2.3 格式差异:不同工具生成的Plist并不一样
不同合图工具导出的Plist,字段名称几乎一样,但表达方式差别很大。我见过的常见差异就有三种:frames可能是dict(键为文件名)或list(元素里再带filename字段);frame的值可能是字符串{{x,y},{width,height}},也可能是嵌套dict;坐标数值有的给整数,有的给浮点数。更麻烦的是文件本体也有两种:一种是标准XML格式的文本Plist,另一种是二进制Plist(文件头是bplist00)。用文本编辑器打开二进制Plist看到的是一堆乱码,直接用字符串解析必然会卡住。
所以写脚本的第一步不是「开始切图」,而是「写一个字段兼容层」。你要让脚本同时能读dict/list、字符串/嵌套dict、文本Plist/二进制Plist。好在主流脚本语言都有现成的Plist解析库(之后会细说),真正需要你手工处理的只有frame、rotated、offset这几个字段的多种写法。兼容层写得越稳,后面换一套资源包时就越省心。
3. 工具选型:现成GUI和自写脚本怎么选,边界在哪
3.1 现成GUI工具能覆盖的场景与覆盖不到的场景
市面上确实有能直接把Plist合图拆成小图的现成工具,图形界面拖进去、选一下输出目录,就能得到一批小图。对于「一次性、量小、只想看图、不需要精确还原原始画布」的场景,用现成GUI工具非常省事。尤其是你只需要确认合图里有哪些素材、大概长什么样时,GUI的预览比命令行方便得多。
但用不了几次你就会发现它的边界。首先是批量处理能力弱:几十上百个Plist文件,GUI工具通常只能一个一个拖进去切,没法在命令行里循环调用;其次是格式兼容性差:很多GUI工具只认某一款合图工具导出的Plist,换一个引擎或者换一种导出设置就完全不认,甚至直接报错。更关键的是,GUI工具往往只在「切出内容区」的层面做事情,帮你把frame区域裁出来,却不处理offset还原、不处理旋转回正;文件数量一多,你根本分不清哪些小图被裁掉了透明边,哪些旋转后横躺。
如果只是临时救急,GUI工具没问题。但如果你要维护一套资源流水线、要反复切同一个项目里的几百个Plist、要保证切出来的结果和原工程一致,GUI工具就会成为瓶颈。
3.2 为什么脚本方案才是通用解:自写脚本的适用边界
脚本方案的优势首先是批量。一个目录遍历加一个循环,几百个Plist几分钟跑完;其次是可控。rotated转不转、offset还原不还原、命名冲突怎么处理、输出目录结构要不要保持,每一行逻辑都在自己手里,出问题时可以直接断点定位。还有一点是可持续集成:脚本可以直接接进构建流水线,作为资源导出前的预处理步骤,切完顺手改名、格式转换、生成清单,都是顺手的事。
那什么时候不要写脚本?如果你只是偶尔切一张图、切完就不再碰,写脚本反而浪费时间。另一个不要写脚本的情况是:你对Plist的字段语义完全不确定,而手头只有一份来路不明的资源包。这时候先手动验一张,确认rotated方向、offset语义,再写成脚本批量跑,否则就是几百张图全部切错。
我一般这样判断:Plist数量超过10个、或者这个资源包未来还会更新、或者需要反复重新切,就写脚本;三个条件都不满足,用GUI工具。当你发现自己第三次用GUI切同一个资源包时,就是写脚本的时候了。
4. 落地实现:用Python脚本把合图分割成小图(思路与可复现代码)
4.1 环境准备与最小可运行脚本
Python + Pillow是这个场景最顺手的组合。Python内置plistlib,读写Plist不需要装额外解析库;Pillow负责裁剪和保存图片。环境准备很简单:
pip install Pillow然后准备一个小目录结构,把Plist和大图放好:
./plist/hero.plist ./img/hero.png ./out/hero/最小可运行脚本如下。它已经处理了dict/list两种frames结构、字符串和dict两种frame写法、二进制和文本两种Plist格式:
import plistlib import os from PIL import Image def parse_rect(value): """兼容两种frame写法:字符串 '{{x,y},{w,h}}' 或 dict {'x':..,'y':..,'width':..,'height':..}""" if isinstance(value, dict): return int(value["x"]), int(value["y"]), int(value["width"]), int(value["height"]) text = value.strip().replace("{", "").replace("}", "") nums = [int(float(part.strip())) for part in text.split(",")] return nums[0], nums[1], nums[2], nums[3] def iter_frames(frames): """兼容frames为dict(键是文件名)和frames为list(元素带filename字段)两种结构""" if isinstance(frames, dict): for name, info in frames.items(): yield name, info else: for info in frames: yield info.get("filename"), info def split_sheet(plist_path, image_path, output_dir, keep_original_size=True): with open(plist_path, "rb") as f: data = plistlib.load(f) sheet = Image.open(image_path).convert("RGBA") frames = data.get("frames", []) os.makedirs(output_dir, exist_ok=True) count = 0 for name, info in iter_frames(frames): x, y, w, h = parse_rect(info["frame"]) # 按frame坐标从大图上裁剪出内容区 sprite = sheet.crop((x, y, x + w, y + h)) # 打包时顺时针旋转过,这里用逆时针转回;相反方向就换ROTATE_270 if info.get("rotated", False): sprite = sprite.transpose(Image.ROTATE_90) if keep_original_size: # 用sourceSize重建原始画布,用offset把内容放回正确位置 source_size = info.get("sourceSize", {}) sx = int(source_size.get("width", w)) sy = int(source_size.get("height", h)) offset = info.get("offset", {}) ox = int(offset.get("x", 0)) oy = int(offset.get("y", 0)) canvas = Image.new("RGBA", (sx, sy), (0, 0, 0, 0)) pos_x = (sx - sprite.width) // 2 + ox pos_y = (sy - sprite.height) // 2 + oy canvas.paste(sprite, (pos_x, pos_y), sprite) sprite = canvas # 防止Plist内部带路径导致输出目录穿越 safe_name = os.path.basename(name.replace("\\", "/")) sprite.save(os.path.join(output_dir, safe_name)) count += 1 return count if __name__ == "__main__": n = split_sheet("./plist/hero.plist", "./img/hero.png", "./out/hero/") print(f"导出 {n} 张小图")这个脚本的核心逻辑只有三件事:解析frame区域、裁剪大图、按需还原画布。plistlib.load在读取二进制Plist时也能自动识别,所以不需要你手动判断文件头。转成RGBA是因为合图里的透明区域在裁剪和粘贴时最容易丢alpha通道,先转RGBA再处理,后面保存PNG就不会出现透明变黑的问题。
4.2 处理旋转与透明裁剪:代码改造与坐标换算
上一小节的脚本里,有两个地方是「按需处理」的:rotated和offset。如果不管它们,旋转图会横躺,去透明边的小图会比原始画布小一圈。这两个问题几乎是所有Plist分割工具翻车的重灾区。
先说rotated。合图工具在打包时,为了不让细长条的小图浪费大量空白区域,会在拼图时把小图旋转90度。Plist里用rotated字段标记。裁剪时,frame的width和height其实已经和原图互换了,所以裁剪出来的图是旋转过的。解决方案就是裁完立刻transpose转回。但旋转方向在不同工具里可能相反:有些工具顺时针转,有些逆时针转。判断方法很简单,找一张明显不接近正方形的小图切出来看:如果转一次后就正常,方向就对了;如果还是横躺,就把Image.ROTATE_90换成Image.ROTATE_270。
再说offset。合图工具裁掉透明边缘后,会记录内容区相对原图中心的偏移。还原原始画布时,需要先创建一个和sourceSize等大的透明画布,再把裁剪内容按偏移放上去。上面代码里的pos_x和pos_y就是「居中 + 偏移」的标准写法。个别工具对offset的语义可能和这个公式差一个正负号,如果发现切出来的图位置不对,把ox和oy取反再试一次。
4.3 参数说明与数据校验:跑完一批后怎么确认没切错
脚本里比较重要的参数是keep_original_size。它控制两种输出模式:True表示还原原始画布尺寸,切出来的小图和美术当初交付的原图尺寸一致;False表示只保留内容区,小图尺寸是紧贴内容的。多数情况下建议用True,否则小图放到UI上会占位错误、总是偏一格。
还有一个隐藏参数是scaleFactor。如果大图是2倍尺寸的资源,Plist里可能会写scaleFactor 2。此时frame坐标是1倍坐标还是2倍坐标,取决于合图工具怎么输出。常见做法是:大图本身是2x图,frame坐标直接按2x处理,不需要额外乘缩放;但有些工具会给出1x坐标,这时候裁剪前需要把x、y、w、h全部乘以scaleFactor。建议拿到资源包后,先看metadata里的size和实际大图尺寸是否一致,很快就能判断出坐标基准,然后再决定要不要做换算。
跑完一批后,做两个校验。第一个是数量校验:脚本输出的count和Plist里frames的条目数应该完全相等。第二个是尺寸冒烟测试:随机抽取几张小图,用图片查看器打开,确认它们不是横躺、不是纯透明、边缘没有明显杂色。如果切了几百张,建议再写一小段自动校验,遍历输出目录,检查每张图尺寸是否非零、是否是合法PNG:
import os from PIL import Image out_dir = "./out/hero/" for f in os.listdir(out_dir): im = Image.open(os.path.join(out_dir, f)) assert im.size[0] > 0 and im.size[1] > 0, f"{f} 尺寸异常" assert os.path.getsize(os.path.join(out_dir, f)) > 0, f"{f} 文件为空"校验脚本本身并不复杂,但它能把「看起来好像切完了」变成「验证过确实切完了」。在几十上百个Plist的规模下,这一步能挡住绝大多数低级错误。
5. 分割小图的常见问题与排查:5条血泪经验
5.1 切出来的图边缘发黑或者颜色不对:先检查alpha通道
现象是内容本身看起来正常,但半透明边缘带上了一圈灰色的黑边,放到深色背景上尤其明显。原因是合图用的PNG可能开启了预乘Alpha,或者图片在打包时边缘混入了背景色。解决方法是先确认源大图的通道:用脚本采样一个半透明像素,如果RGB值大于Alpha值,说明不是标准的直通Alpha,需要先做unpremultiply再保存。边角特别锐利的图也常见这个问题,切完不要直接交出去,放到实际背景里看一眼。
5.2 旋转图横躺:rotated字段没处理,或者方向搞反
这是最容易大规模翻车的一类问题。现象是切出来的一批图里,一部分看着很正,另一部分是横躺的;看起来完全没有规律。原因是这些横躺的图在合图时被旋转过,Plist里rotated为true,你的脚本没有做回正。解决方法是裁剪后根据rotated字段统一transpose。注意旋转方向不同工具不一样:如果转一次后发现所有图从「横躺」变成「竖躺但仍然不对」,就把ROTATE_90改成ROTATE_270。我现在的习惯是第一次跑批前,先挑选一张最不像正方形的图单独验证方向,确认没问题再全量跑。
5.3 切出来的图比原图小:offset和sourceSize没还原
现象是脚本输出的每张图都「很干净」,但放到界面里总是位置偏移,或者和动画骨骼对不上。原因是合图时工具裁掉了透明边缘,只保留实际内容区域,而你的脚本是直接按frame裁剪保存,没有用sourceSize重建画布。解决方法是开启keep_original_size逻辑,先创建和sourceSize等大的透明画布,再把裁剪内容按offset放回画布中央。这样切出来的小图尺寸、透明边距都和美术原始输出一致。值得注意,个别工具里offset的符号习惯和常规相反,如果位置总是正好反方向偏移一截,把ox和oy取反即可。
5.4 读取Plist报错或者读出来全是乱码:二进制Plist和编码问题
现象是plistlib.load抛异常,或者解析出来frames是空的。原因是文件可能是二进制Plist,你在用文本方式读取;也可能文件是UTF-8带BOM,直接读把BOM字符带进了解析器。解决方法是统一用二进制模式打开文件:open(plist_path, "rb"),plistlib会自行判断文本格式还是二进制格式。如果发现文件头不是bplist00也不是<?xml,那么这个文件可能根本不是Plist,而是JSON或其他格式,直接用json.load替代解析。编码问题在Windows环境里尤其常见,不要在代码里硬编码编码方式,让plistlib自己判断最稳。
5.5 边缘出现一圈相邻小图的杂色:坐标精度和padding问题
现象是某些小图边缘多了一条颜色怪异的像素带,仔细看能看出是隔壁小图的边。原因是合图工具在拼图时为了过滤采样,会在小图之间留padding,而Plist里记录的frame区域可能包含了这层padding;另一个可能是坐标解析时用了浮点数直接取整,误差累积导致偏了一两个像素。解决方法是先确认frame到底指内容区还是内容区加padding。如果是后者,裁完再往内收缩几个像素:x加padding、y加padding、width减两倍padding、height减两倍padding。坐标取整则统一用int(float()),不要反复round,避免边界抖动。
6. 进阶用法:从单张切图到批量校验流水线
6.1 批量遍历目录、自动匹配大图
单个Plist切完只是开始,实际项目里资源都是成百上千的。写一个递归遍历函数,自动找到所有Plist并匹配同名大图,就能一键全量导出:
import glob import os for plist_path in glob.glob("assets/**/*.plist", recursive=True): image_path = os.path.splitext(plist_path)[0] + ".png" if not os.path.exists(image_path): print(f"跳过 {plist_path}:缺少同名大图") continue out_dir = plist_path.replace("assets", "out") n = split_sheet(plist_path, image_path, out_dir) print(f"{plist_path}: {n} 张小图")这段逻辑里有一个容易忽略的点:大图文件名和Plist文件名通常一致,但扩展名可能是png也可能是jpg。稳妥做法是先试png再试jpg,两个都不存在才跳过。批量跑的时候建议把跳过原因打印出来,否则某个资源包漏切了你可能很久之后才发现。
6.2 用manifest记录切图结果,建立可对比的基线
当切图变成流水线的一环之后,光有输出文件还不够,建议同时生成一份manifest清单,记录每个小图的文件名、尺寸、输出时间。下次跑批时,先读旧manifest,和这次结果做对比:新增了哪些文件、删除了哪些、哪些尺寸变了。这个对比能很快暴露资源包是否更新过、脚本参数是否改动过,也能让美术和工具链同学在出问题时直接定位到具体差异。
我自己的习惯是:脚本必须输出一个exit code,切图过程中有任何一张图校验失败就非零退出。这样流水线里下一道环节才能知道这一步失败,而不是默默把错误结果往后传。最后说一个翻车教训:之前做某个资源还原项目时,忘了处理rotated字段,结果几百张序列帧切出来全是横躺的,花了整整半天才定位到问题。那次之后我把「先验证一张再全量跑」写进了固定流程,跑批之前先挑一张非正方形的小图切出来人工看一眼旋转方向。这条习惯一直保留到现在。希望帮到你。
本文还有配套的精品资源,点击获取