cocos2d图集合并与plist生成:imgsTools实践

发布时间:2026/11/5 2:09:48
cocos2d图集合并与plist生成:imgsTools实践

简介:imgsTools 是一款面向游戏开发者的图片合并工具,基于 VALib 图像处理库以及 MFC、GDI+ 技术构建,主要用于把多张素材图合并输出为透明 PNG、GIF 或 JPEG,并同步生成 Cocos2d 引擎可直接导入的 .plis 资源清单,解决 2D 项目中图集打包与资源批量配置繁琐的问题,适合具备一定 C++ 与 Windows 开发基础、需要处理图集合并的开发者参考。压缩包共 113 个文件,约 3.75MB,包含 31 个 .h 头文件与 25 个 .cpp 实现文件,涵盖 VAArray、VAGeometry、VASprite、RectPlacement、ImageView 等模块;另附 lib 静态库、vcxproj 工程文件与 png、jpg、gif 示例图片,以及 obj、pdb、ilk 等编译中间产物和可执行文件,便于直接编译运行或按模块拆读。已有 346 人学习下载,可对照源码理解图集合并与 .plis 生成的完整实现路径,也可借鉴其中的 MFC 界面组织与 GDI+ 渲染写法用于自有工具开发。

1. 从 37 张按钮图到一张纹理:imgsTools 图片合并工具在 cocos2d 管线里的位置

美术给过来 37 张 128×128 的按钮图,单独加载时每个按钮一次纹理切换,进战斗界面 draw call 直接顶到 80 多;用 imgsTools 图片合并工具打成一张 1024×1024 图集后,批次合并到 1,但图集边缘冒出一条 1px 黑边。这类问题不是美术能修的,得回到合并工具的像素格式、padding 和 plist 偏移参数里查。imgsTools 是面向 cocos2d 资源管线的桌面端图片合并工具,界面层用 MFC,图像解码与合成用 GDI+,输出 atlas.png 和 cocos2d 可读的 plist。它适合还在维护 Windows 工具链、需要批量处理 UI 图和序列帧的小团队,也适合想给自研编辑器补一个可控图集入口的开发者。它不解决美术规范,但能把“散图进、图集和坐标表出”这条路固定下来,让后面每次合图都有日志可查。

2. 图集合并的底层账:cocos2d 批次、纹理页与 MFC/GDI+ 的取舍

图片合并工具最容易写成一个“拼图器”:把图片按顺序贴到画布上,导出 PNG 就算完。真放进 cocos2d 项目里,拼图器只能算半成品。cocos2d 的自动批处理看的是纹理页和渲染顺序,图集边界、透明像素、plist 偏移任何一项对不上,运行时就会用黑边、错位或者纹理内存翻倍来提醒你。imgsTools 的定位不是替代美术切图,而是把“按业务域分图集、按固定参数装箱、输出坐标表、生成 plist”这条链路做成可重复执行的工序。MFC 负责目录选择、预览和日志窗口,GDI+ 负责 PNG 解码、32bpp ARGB 合成和 PNG 编码,选型不新,但胜在 Windows 端部署简单,源码改起来也直接。

2.1 为什么 cocos2d 项目需要图集:draw call 与纹理切换

cocos2d 系引擎在渲染相邻 Sprite 时,如果它们来自同一张纹理,并且中间没有插入其他纹理的绘制命令,就有机会合并成一个批次。图集把多张小图塞进一张纹理,本质是减少纹理切换次数,让原本 37 次纹理绑定变成 1 次。这里有个反直觉点:不是“合并了就一定降 draw call”。如果 UI 图集和场景图集交替绘制,或者图集里混进了不同压缩格式的纹理,批次仍然会被打断。我一般按业务域拆图集:公共按钮和弹窗底板放 ui_common,战斗内血条和技能图标放 ui_battle,序列帧单独放 fx_xxx。每个图集对应一个加载时机,避免把整包 UI 一次性塞进显存。

图集尺寸也不是越大越好。常见做法是把单张纹理控制在 2048×2048,老设备或低端机型再降到 1024×1024;超过这个尺寸,部分 GPU 的最大纹理尺寸虽然支持,但加载时间和内存峰值会很难看。POT,也就是 2 的幂,在老 OpenGL 设备上更稳,现代设备不强制,但工具里留一个开关比事后改代码便宜。透明通道要特别小心:带 alpha 的图如果被压成不支持的格式,边缘会出现硬切或黑边。图集参数建议在工具里固化成配置,不要每次靠手填。

参数常见取值影响
最大纹理尺寸1024 / 2048超过设备上限会加载失败,越大内存峰值越高
padding2px 起防止线性过滤采样到相邻图
extrude1px把边缘像素向外扩一圈,压黑边
POT开 / 关老设备兼容性与显存占用之间的取舍
trim按业务开去掉透明边省面积,但必须写对 offset

2.2 MFC + GDI+ 的选型理由:桌面端批处理与小团队维护

很多团队第一反应是用 Photoshop 动作或者在线拼图,短期能出图,长期会卡在三个地方:透明通道处理不透明、命名和目录规则靠人记、坐标表需要手工对。imgsTools 用 MFC 做壳,不是为了追求界面多漂亮,而是 Windows 上目录浏览、拖拽、进度条、日志窗口这些基础能力现成,工具链部署到美术机也不用装运行时。GDI+ 负责读 PNG、转 32bpp ARGB、逐像素合成、再存 PNG,API 不复杂,Bitmap::LockBits 拿到像素缓冲区后,装箱和拷贝都能自己控制。对比直接调引擎的 TexturePacker,自己维护一份 GDI+ 实现的好处是参数完全透明,坏处是高级压缩格式得另接命令行工具。

GDI+ 的坑主要在像素格式和绘制模式。源图可能是 24bpp RGB,也可能是 32bpp ARGB,甚至带索引色;直接 DrawImage 到目标 Bitmap 上,默认混合模式会把透明区域和背景混在一起。我一般先把每张图统一转成 PixelFormat32bppARGB,再用 CompositingModeSourceCopy 做 1:1 拷贝,插值模式设成 NearestNeighbor。这样图集里的像素就是源图像素,不会在合并阶段被二次采样。MFC 这边只做状态管理:读目录、过滤扩展名、把文件列表交给合并核心、把每条失败记录写进日志。界面和算法分开,后面换装箱策略不用动窗口代码。

2.3 合并模式与目录约定:一张图集对应一个业务域

目录约定比算法更影响长期维护。我的习惯是输入目录按业务域分:ui/common、ui/battle、ui/shop,每个目录下只放需要进同一张图集的 PNG,文件名用“模块_用途_状态”这种能排序的格式,比如 btn_ok_normal.png、btn_ok_pressed.png。工具扫描目录时按文件名排序,保证同一批资源每次合并结果稳定;需要排除的源文件放 _exclude 子目录,或者用.psd、_raw.png 这种规则过滤。输出目录固定成 atlas.png、atlas.plist、atlas.json、atlas.log 四个东西,日志里记录每张图的原始尺寸、裁剪后的矩形、在图集中的坐标、是否旋转、是否 trim。

合并模式上,imgsTools 这类工具通常提供两种入口:整目录合并和清单合并。整目录适合 UI 这种“一个目录一张图集”的场景;清单合并适合序列帧或跨目录资源,用一份文本列出要打进图集的路径和逻辑名。清单模式的好处是逻辑名可以和文件名脱钩,比如 sprites/hero_run_01.png 在 plist 里叫 hero_run_01,不带目录和扩展名,cocos2d 里取 frame 更稳。无论哪种模式,输出前都要做一次重名检查:同一图集里两个文件映射到同一个 key,加载时后者会覆盖前者,这种问题在日志里不显眼,运行时才翻车。

3. 用 GDI+ 合并散图:像素格式、装箱排序与 PNG 导出的可复现步骤

真正动手写合并核心时,顺序比代码量重要。先把所有源图读成统一的 32bpp ARGB,记录原始宽高和透明边;再按高度降序做装箱,给每张图分配图集坐标;最后按坐标把像素拷贝到一张大 Bitmap 上,导出 PNG 和坐标表。GDI+ 在这里承担解码、像素访问和编码三件事,但每一步都有参数会改变结果。下面这套流程是我在 MFC 工程里常用的拆法,代码只保留关键路径,方便你对照自己的工程改。

3.1 读图与像素格式:先把 32bpp ARGB 锁死

源图格式不统一是图集黑边的第一来源。24bpp 的 PNG 没有 alpha,GDI+ 读进来再画到带透明背景的画布上,透明区域可能被填成黑色;索引色 PNG 在缩放或绘制时也容易出问题。我的做法是读入后立刻转一张 32bpp ARGB 的临时 Bitmap,后续所有裁剪、拷贝都只认这个格式。转换时用 SourceCopy,避免 GDI+ 默认的 SourceOver 把源图和目标背景混合。下面这段是核心转换逻辑,生产代码里会把 Bitmap 包成 RAII 对象,避免异常路径泄漏。

// 读入后统一转 32bpp ARGB,保证 alpha 不被丢弃 Gdiplus::Bitmap* LoadAsArgb(const CString& path, CString& err) { Gdiplus::Bitmap src(path); if (src.GetLastStatus() != Gdiplus::Ok) { err = L"decode failed: " + path; return nullptr; } auto* dst = new Gdiplus::Bitmap( src.GetWidth(), src.GetHeight(), PixelFormat32bppARGB); Gdiplus::Graphics g(dst); // 1:1 拷贝,不让 GDI+ 做混合和插值 g.SetCompositingMode(Gdiplus::CompositingModeSourceCopy); g.SetInterpolationMode(Gdiplus::InterpolationModeNearestNeighbor); g.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHalf); g.DrawImage(&src, 0, 0, src.GetWidth(), src.GetHeight()); return dst; }

这段代码的关键参数是 PixelFormat32bppARGB 和 CompositingModeSourceCopy。前者把像素布局固定成每像素 4 字节,后面 LockBits 读 stride 时不容易算错;后者保证源图的 alpha 原样写入,不会和目标 Bitmap 的初始颜色做混合。InterpolationModeNearestNeighbor 是给 1:1 绘制兜底,PixelOffsetModeHalf 避免半像素偏移。实际工程里还要检查源图尺寸是否为 0、路径是否含中文、GDI+ 是否在 UI 线程初始化。GDI+ 的启动和关闭放在应用级,不要每张图 StartUp 一次,否则批量处理时会明显变慢。

读进来之后不要急着裁透明边。先记录原始宽高 sourceW、sourceH,再扫描 alpha 通道找有效像素范围。扫描时按行读 LockBits 的 Scan0,stride 可能是负数,方向要从 BitmapData 的 Stride 判断。透明边裁掉以后,图集坐标里的 w、h 是裁剪后的尺寸,plist 里的 sourceSize 仍然是原始尺寸,offset 要根据裁剪偏移算。这个顺序一旦反过来,后面 plist 的偏移会全部对不上。

3.2 装箱排序:高度降序加 shelf 不是最优,但足够可解释

图片合并的装箱算法有很多,MaxRects、Skyline、Shelf 都能用。imgsTools 这种桌面工具不需要追求极限利用率,稳定和可解释更重要。我一般用高度降序的 shelf:先按高度从高到低排,高度相同按宽度降序;从左到右放,放不下就换行,行高取当前行最高图的高度。每张图之间留 padding,图集边缘也留 padding,防止采样到边界外。这个算法实现短,日志里能清楚看到每张图落在第几行,出问题容易复现。下面用 Python 伪码说明装箱过程,C++ 侧只是把 list 换成 vector,把坐标写回结构体。

def shelf_pack(images, max_w, padding): # 高度降序,宽度降序,保证同一批资源结果稳定 images.sort(key=lambda im: (-im.h, -im.w)) x = y = row_h = 0 for im in images: need_w = im.w + padding * 2 if x + need_w > max_w: x = 0 y += row_h + padding row_h = 0 im.x = x + padding im.y = y + padding x += need_w row_h = max(row_h, im.h + padding * 2) return images

参数里 max_w 是图集可用宽度,通常取 max_size 减去两侧 padding;padding 建议从 2 开始,带线性过滤的 UI 图可以到 4。row_h 记录当前行最高图加 padding 的高度,换行时 y 加上它。这个算法不处理旋转,也不做多页图集,但胜在每次输出顺序一致,日志能对上。真正落地时还要加一道“图集高度是否超过 max_size”的检查,超了就拆成 atlas_0.png、atlas_1.png,不要硬塞进一张超大图。拆页后 plist 也要按页生成,加载时分别注册。

装箱完成后,每张图会得到 x、y、w、h 四个值,其中 x、y 是图集内左上角坐标,w、h 是裁剪后的宽高。如果是 trim 过的图,还要保留 trimX、trimY 和原始 sourceW、sourceH;没有 trim 的图,trimX、trimY 为 0。坐标表里建议同时写 rotated 字段,即使当前不旋转,后面换算法也不用改 plist 生成器。坐标表用 JSON 或 CSV 都行,关键是字段名固定,生成 plist 的脚本只依赖这份中间产物,不直接读图片目录。

3.3 写 PNG 与坐标表:保存透明通道,别让 GDI+ 二次采样

合成阶段最容易翻车的是“看起来对,边缘不对”。目标 Bitmap 先按图集尺寸创建成 32bpp ARGB,初始像素全透明;然后按每张图的 x、y 用 DrawImage 画上去。这里不要再做任何缩放,源矩形和目标矩形都用原始像素尺寸。GDI+ 的 DrawImage 重载很多,建议用带 srcRect 和 dstRect 的版本,明确指定像素单位。画完以后用 PNG 编码器保存,不要存 JPEG;JPEG 没有 alpha,图集边缘会直接变黑。下面这段是保存 PNG 的关键调用,编码器 CLSID 通过 GetEncoderClsid 查一次缓存起来。

// 保存 PNG,保持 alpha 通道;不要用 JPEG CLSID pngClsid; GetEncoderClsid(L"image/png", &pngClsid); Gdiplus::Status st = atlas.Save(outPath, &pngClsid, nullptr); if (st != Gdiplus::Ok) { log.Error(L"save png failed: %s", outPath); }

保存前要检查目标 Bitmap 的 PixelFormat 还是 32bpp ARGB,有些 GDI+ 路径会在 Graphics 构造后改变格式。Save 的第二个参数是编码器 CLSID,第三个参数是 EncoderParameters,质量参数对 PNG 是压缩级别,不是 JPEG 那种质量,保持默认即可。输出文件名固定为 atlas.png,plist 里的 textureFileName 要和它一致;如果拆页,文件名带序号,plist 也按页生成。坐标表输出成 atlas.json,字段包括 name、x、y、w、h、sourceW、sourceH、trimX、trimY、rotated。生成 plist 的脚本只读这份 JSON,不重复扫图,避免两次扫描结果不一致。

import json manifest = [{ "name": "btn_ok_normal", "x": 2, "y": 2, "w": 120, "h": 48, "sourceW": 128, "sourceH": 64, "trimX": 4, "trimY": 8, "rotated": False }] with open("atlas.json", "w", encoding="utf-8") as f: json.dump({ "texture": "atlas.png", "width": 1024, "height": 1024, "frames": manifest }, f, ensure_ascii=False, indent=2)

这份 JSON 是合并核心和 plist 生成器之间的契约。name 用不带目录、不带扩展名的逻辑名;x、y 是图集内左上角坐标;w、h 是裁剪后尺寸;sourceW、sourceH 是原图尺寸;trimX、trimY 是透明边裁掉的偏移。rotated 目前为 False,但字段先留着。写文件时统一 UTF-8,避免中文文件名在 Windows 和引擎里编码不一致。日志里把每张图的这些字段再打一遍,后面 plist 偏移不对,直接拿日志和 JSON 对,不用重新跑合并。

4. 生成 cocos2d plist:frame 计算、锚点偏移与加载校验

图集 PNG 出来只是半成品,cocos2d 认的是 plist 里的 frame、offset、sourceSize 这些字段。很多“图集加载成功但显示错位”的问题,不是 PNG 坏了,而是 plist 里的坐标原点方向或者 offset 符号写反。imgsTools 的输出最好把 plist 生成做成独立步骤:合并核心只负责 PNG 和 JSON,plist 生成器读 JSON 产出 XML,这样你可以单独重跑 plist,不用重新合图。下面按字段解释和脚本生成两步走,最后给一个加载前后的校验方法。

4.1 plist 的 frame 与 sourceSize:四个数字别写反

cocos2d 的 SpriteFrameCache 读 plist 时,最常看的是 frames 字典里的 frame、offset、rotated、sourceColorRect、sourceSize。frame 是这张图在图集纹理里的矩形,格式通常是 {{x,y},{w,h}};sourceSize 是原图尺寸,格式 {w,h};offset 是裁剪后 sprite 中心相对原图中心的偏移;sourceColorRect 表示原图中有效像素区域,没做 trim 时就是 {{0,0},{w,h}}。这几个字段里,frame 的 x、y 是图集坐标,不是原图坐标;sourceSize 必须写原图宽高,不能写裁剪后的宽高,否则锚点和碰撞框会偏。

坐标原点方向是血泪经验。cocos2d 的 plist 在工具链里通常按左上角原点记录 frame,但引擎内部纹理坐标可能是另一套方向。自己写生成器时,最稳的办法是固定一套:合并工具按左上角记录 x、y,plist 生成器也按左上角写 frame,然后用一张 2×2 像素、四个角颜色不同的测试图加载,看上下有没有翻转。不要一边按左上角写、一边按左下角算,最后靠“看起来差不多”蒙混过去。offset 的符号也要固定,常见公式是 offsetX = sourceW/2 - (trimX + w/2),offsetY = sourceH/2 - (trimY + h/2),但不同引擎版本可能有差异,必须用测试图验证。

字段含义常见错误
frame图集内矩形 {{x,y},{w,h}}把原图坐标写进来
sourceSize原图尺寸 {w,h}写成裁剪后尺寸
offset裁剪中心相对原图中心偏移符号反了,sprite 偏移半个身位
sourceColorRect原图有效像素矩形trim 后仍写 {{0,0},{w,h}}
rotated是否旋转工具不旋转却写 true

4.2 用 Python 生成 plist:从 manifest 到 SpriteFrameCache 可读

有了 atlas.json,生成 plist 的脚本就很短。用 plistlib 写 XML,frames 的 key 用逻辑名,metadata 里写 format 3、size、textureFileName。size 是图集纹理尺寸,不是原图尺寸;textureFileName 必须和实际 PNG 文件名一致,路径用相对路径,不要写绝对路径。下面这段脚本直接读上一节的 JSON,生成 atlas.plist。offset 用整数,避免 plist 里出现 0.5 这种浮点导致不同解析器行为不一致;如果确实需要半像素,先确认引擎版本支持。

import json import plistlib def rect(x, y, w, h): return f"{{{{{x},{y}}},{{{w},{h}}}}}" with open("atlas.json", encoding="utf-8") as f: manifest = json.load(f) frames = {} for it in manifest["frames"]: x, y, w, h = it["x"], it["y"], it["w"], it["h"] sw, sh = it["sourceW"], it["sourceH"] tx, ty = it.get("trimX", 0), it.get("trimY", 0) frames[it["name"]] = { "frame": rect(x, y, w, h), "offset": f"{{{int(sw / 2 - (tx + w / 2))},{int(sh / 2 - (ty + h / 2))}}}", "rotated": bool(it.get("rotated", False)), "sourceColorRect": rect(tx, ty, w, h), "sourceSize": f"{{{sw},{sh}}}" } data = { "frames": frames, "metadata": { "format": 3, "size": f"{{{manifest['width']},{manifest['height']}}}", "textureFileName": manifest["texture"] } } with open("atlas.plist", "wb") as f: plistlib.dump(data, f)

脚本里 rect 函数负责拼 cocos2d 认识的矩形字符串,少一层花括号都会解析失败。offset 用 int 截断,前提是 sourceW 和 w 的奇偶性一致;如果美术给的图是奇数尺寸,截断会带来 0.5 像素偏移,这时要么把 offset 改成浮点并确认引擎支持,要么在合并阶段把图集坐标对齐到偶数。metadata 的 format 写 3,对应 SpriteFrameCache 的 plist 格式;size 写图集宽高;textureFileName 写 atlas.png。生成完先用 plistlib 读回来,确认 frames 数量和 JSON 一致,再进引擎加载。

4.3 加载校验:用 cocos2d 最小场景和 plist 解析器双查

plist 生成后不要直接扔进正式工程。先写一个最小校验脚本,检查 frame 是否越界、frame 之间是否重叠、padding 是否足够、每个 key 是否唯一。然后在 cocos2d 里建一个空场景,把 atlas.png 和 atlas.plist 放进去,用 SpriteFrameCache::getInstance()->addSpriteFramesWithFile("atlas.plist") 加载,再逐个 createSprite 并打印 getContentSize、getOffset、getRectInPixels。重点看三件事:取不到 frame 的 key 有多少、sprite 尺寸和原图是否一致、带透明边的图位置有没有偏。校验脚本不用很复杂,能挡住“越界”和“重名”两类问题就值回时间。

import plistlib with open("atlas.plist", "rb") as f: data = plistlib.load(f) keys = set() for name, info in data["frames"].items(): if name in keys: raise SystemExit(f"duplicate frame: {name}") keys.add(name) frame = info["frame"] # 这里只做存在性检查,实际项目还要解析矩形并检查越界 assert "{" in frame and "}" in frame, name print("frames:", len(keys), "texture:", data["metadata"]["textureFileName"])

这段校验只做了 key 唯一性和字段存在性,真实工程里我会把 frame 字符串解析成四个整数,再和图集宽高比较,检查 x+w 是否超过纹理宽度、y+h 是否超过高度;同时检查两张图的矩形是否相交,相交就说明 padding 不够或者装箱坐标写错。运行时校验则看 SpriteFrame 的 rect 和 offset,和 JSON 里的值对一遍。两边都对上,再批量替换正式资源。加载失败时先看 textureFileName 是否带路径、plist 的 format 是否为 3、PNG 是否真的存在,这三类问题占了大多数。

5. 避坑与排查:黑边、错位、内存翻倍和 plist 加载失败的现场记录

图集工具的问题很少在“合并成功”那一刻暴露,通常是在美术验收、真机加载或者版本更新后才出现。下面几条是 imgsTools 这类 MFC + GDI+ 工具在 cocos2d 项目里最常见的翻车现场,每条按现象、原因、解决写,方便你直接对着日志排查。参数不要凭感觉调,先把 1px 边界测试图和三张带透明边的按钮图跑一遍,确认没有黑边和错位,再处理正式资源。

5.1 边缘与透明像素类:1px 黑边不是美术描边

现象:图集加载后,按钮边缘出现一条 1px 黑线或白线,单独看源图正常,合图后才有。原因通常有三个:padding 太小,线性过滤采样到了相邻图的像素;透明区域的 RGB 是黑色,缩放时把黑色带进边缘;GDI+ 绘制时用了默认插值或混合模式,把 alpha 边缘重新混合了一遍。解决顺序是先看 padding 和 extrude,再看源图透明像素颜色,最后查合并代码的 CompositingMode 和 InterpolationMode。

padding 至少给 2px,带缩放动画的 UI 给 4px;extrude 把边缘像素向外扩 1px,能明显压住黑边。透明像素的 RGB 不要留黑,合并前做一次 bleed,把边缘颜色向透明区扩散几像素,或者让美术导出时带背景色再抠。GDI+ 侧统一用 SourceCopy 和 NearestNeighbor,别在合并阶段做平滑。还有一点容易忽略:PNG 保存后如果被其他工具二次压缩,alpha 边缘可能被量化,图集边缘又会变硬。输出后直接用引擎加载原图对比,不要只看 Windows 图片查看器。

5.2 坐标错位与缩放类:offset 符号反了会偏半个身位

现象:图集里的 sprite 能显示,但位置比原来偏左或偏上,带透明边的图偏得更明显;有的图尺寸看起来对,锚点却不在中心。原因多半是 plist 的 offset 符号写反,或者 frame 的坐标原点方向和引擎不一致;也有可能是 trim 后 sourceSize 写成了裁剪尺寸,导致锚点计算基准错了。还有一种情况是图集被二次缩放,比如工具按 2048 输出,加载时又按 1024 加载,坐标全部对不上。

解决方法是固定一套坐标约定,并用测试图验证。合并工具按左上角记录 frame,plist 也按左上角写,offset 公式统一成 sourceW/2 - (trimX + w/2),sourceH/2 - (trimY + h/2)。做一张 64×64 的图,四角分别涂红、绿、蓝、黄,中间放一个 1px 点,加载后看四个角的位置和中心点,翻转或偏移一眼就能看出来。trim 过的图必须保留 sourceW、sourceH 和 trimX、trimY,sourceSize 写原图尺寸。图集 PNG 不要在上传或打包阶段缩放,确需多倍图就分别出图集,不要靠运行时缩放同一张图。

5.3 内存翻倍与纹理尺寸类:合并后反而更吃显存

现象:散图时内存还能接受,合并成一张 2048×2048 后显存直接翻倍,低端机加载变慢甚至闪退。原因通常是图集尺寸选得太大、RGBA8888 未压缩、同一批资源重复打进多个图集,或者开了 mipmap 但 UI 根本用不到。2048×2048×4 字节大约是 16MB,一张两张看不出,图集一多就很明显。还有团队把不同场景的图塞进同一张公共图集,结果每个场景都加载整张图,常驻内存降不下来。

解决方法是按业务域拆图集,控制单张尺寸,能 1024 就不上 2048;UI 图集和场景图集分开加载,退出场景时释放对应图集。压缩纹理要看格式是否支持 alpha,ETC2 和 PVRTC 对透明边的处理不同,压完必须用测试图检查边缘。mipmap 对 3D 场景有用,UI 图集一般关掉。合并前去重,同一张图不要同时进 common 和 battle 两个图集;如果确实需要共用,抽到 common 并调整加载顺序。日志里记录每张图集的宽高和估算字节数,版本迭代时能及时发现膨胀。

5.4 plist 加载失败与命名冲突类:取不到 frame 先查 key

现象:addSpriteFramesWithFile 返回成功,但 createSprite 取不到某个 frame,或者取到的图是错的;有时控制台没有明显报错,只是 sprite 不显示。原因可能是 plist 的 key 带了扩展名或目录,代码里用不带扩展名的名字取;也可能是两个源文件映射到同一个逻辑名,后写入的覆盖了前者;还有 textureFileName 带了绝对路径,换机器后找不到 PNG。plist 的 format 不是 3 时,部分引擎版本解析行为也会变。

解决方法是合并阶段就做 key 规范化:统一去掉目录和扩展名,只保留文件名,重名直接报错并停止输出。plist 里 textureFileName 用相对路径,和 plist 放在同一目录。加载前用 plistlib 读一遍,检查 frames 数量、key 是否重复、textureFileName 是否为空。运行时如果取不到 frame,先打印所有 key 的前几个,确认大小写和下划线;再检查 PNG 是否在搜索路径里。命名冲突这种问题在日志里不显眼,但运行时一定翻车,最好在工具里做成硬失败,不要只给警告。

6. 进阶技巧:透明边裁切、九宫格标记与自动巡检

透明边裁切能省面积,但不是所有图都适合。普通按钮、图标可以裁,九宫格图不能裁,因为九宫格的边距依赖原图尺寸;序列帧如果每帧都裁,offset 会逐帧变化,播放时容易抖。我的做法是在文件名或清单里加标记:_9slice 的图跳过 trim,_anim 目录下的序列帧统一按最大边框裁,不逐帧裁。合并前跑一遍巡检脚本,检查 plist 里 frame 是否越界、是否有重叠、padding 是否小于 2、九宫格图是否被误裁。下面这张表是我常用的默认参数,落地时按项目改。

场景paddingextrudetrim备注
普通 UI 按钮21开带缩放动画时 padding 加到 4
九宫格底板41关边距依赖原图尺寸
序列帧21按目录统一不要逐帧裁,避免 offset 抖动
图标合集21开按业务域拆图集,控制 1024/2048

巡检脚本不用写得很重,能把“越界、重叠、padding 不足、重复 key”四类问题拦住就够。每次合并完先跑脚本,再进引擎最小场景加载,最后才替换正式资源。从那以后我每次合并图集都强制先跑一遍 1px 测试图和三张边界图,确认黑边、偏移和 plist 都能对上,再批量处理正式资源。希望帮到你。

本文还有配套的精品资源,点击获取