图集(Sprite Atlas)在 Unity 项目里算是个“沉默的基建”——平时没人聊它,一旦 UI 掉帧、DrawCall 高到离谱、包体莫名其妙膨胀,所有人第一时间就去看它。这篇把 Unity 图集属性逐项拆开讲透,配上可直接抄的代码示例,最后给一套一键自动打包图集工具的完整实现,顺手接到日常流程和 CI 里。不管你是刚接手 UI 模块、还没搞明白 Sprite Atlas 面板上那一堆勾选框的新人,还是已经在用图集但卡在 Variant、晚绑定、打包时机上的老手,都能从这里拿走点能直接用的东西。尤其是那几个只能在真机上才暴露出来的坑,我会把排查路径一并写清楚。
1. 先把图集这件事的来龙去脉理清楚
1.1 一次 DrawCall 暴涨暴露出的问题
去年接手的项目里有个经典场景:主界面 UI 有 60 多个 Image,美术出图是散的 PNG,每个 Image 一个 Sprite。跑真机一看,UI 的 DrawCall 直接飙到 70 多,帧率在低端机上掉到 22 帧。当时的做法是把这些图按模块拖进一个图集里,DrawCall 一夜之间从 70 降到 6,帧率回稳。这个例子说明的事情其实很朴素:UI 合批的判定条件是相邻的绘制项共用同一个材质和纹理,只要纹理不同,中间就会被强行打断一次并生成新的批次。散图意味着每个 Sprite 一张纹理,合批从根上就不可能成立。
图集的本质就是把 N 张独立的纹理预先烘焙到一张大纹理上,同时把每张 Sprite 的 UV 重映射到大图的对应区域。运行时渲染时,所有图集内的 Sprite 共用同一张大图,材质相同、纹理相同,合批自然成立。这里有个容易被忽略的细节:合批还要求绘制顺序连续。如果你的 UI 层级里 A 图集的图、B 图集的图、又一张 A 图集的图交替排列,那合批一样会被打断,跟有没有用图集无关。所以真实项目里挑图集划分方案时,通常不是按“图片类型”分,而是按“界面出现的位置和层级顺序”分,比如按模块拆成“通用 UI”“战斗 HUD”“商城”几本图集。
另一个必踩的点是 Image 的 Raycast Target 和 Mask 组件。哪怕纹理完全一致,一旦中间夹了 Mask/被 Mask 包裹,或者某个 Image 组件的材质被改过(比如加了自定义 Shader),合批照样断开。所以排查 DrawCall 的时候别只盯着图集,用 Frame Debugger 一步步看批次是怎么被打断的,比猜效率高得多。
1.2 Sprite Atlas V1 与 V2 的差别,以及为什么别再手动拼大图
早期没有 Sprite Atlas 的时候,很多团队的做法是手动把散图拼成一张大图,再用 Sprite Editor 切分,写工具维护坐标表。这套流程的痛点极其明显:美术换一张图,坐标表就要重算,切图位置全靠人肉对,稍有不慎就是错位。后来 Unity 内置了 Sprite Packer,再演进到现在的 Sprite Atlas,才把“拼图 + 记坐标”这件事交给引擎自动做。
Unity 2019.3 之后引入了 Sprite Atlas V2,切换入口在 Project Settings > Editor > Sprite Packer > Mode,可选 Disabled、Sprite Atlas V1、Sprite Atlas V2。2021.2 之后的默认值是 V2。两者的差异主要在这几处:V2 把图集资产的序列化格式改成了更稳的结构,图集缓存的管理方式也变了,V2 支持在编辑器里对图集做增量重打包,改一两张图不用整本重来;另外 V2 对晚绑定(late binding)的支持更完整,这是后面代码部分要重点用的机制。
一个很多人不知道的事实:图集烘焙出来的大纹理不会落到 Assets 目录里,它存在工程的 Library 下的图集缓存目录中。这意味着你没法用版本管理去 diff 图集纹理,也别想在 Git 里看到“图集变大了”的提示。团队协作时如果两个人分别改了图集里的不同图片,合并的是 .spriteatlas 这个资产文件本身,而不是纹理,冲突一般出现在资产文件上。所以工程规范里最好约定:一个模块的图集由一个人统一维护,或者干脆用后面那套工具在 CI 上统一重建,避免人工合并。
还有一点要提前说清楚:Sprite Atlas 默认在 Build 时才会真正烘焙。编辑器里按 Play 跑起来看到的是缓存版本,如果你改了图集内容却没重新打包,跑出来的图可能还是旧的,这种“编辑器里是对的、包出来是错的”问题我见过太多次了。这也是为什么需要一个一键打包工具——把打包动作变成一个显式的、可重复执行的操作,而不是靠引擎的隐式时机。
2. Sprite Atlas 面板属性逐项拆解
2.1 主面板那些勾选框到底在控制什么
打开一个 .spriteatlas 资产,Inspector 上的字段不算多,但每一个都会影响最终包体和内存,我按重要性排一下,顺便把“为什么要这么设置”讲清楚。不同 Unity 版本字段位置会有微调,但语义是一致的。
| 属性 | 可选值 | 实际作用 | 我一般怎么设 |
|---|---|---|---|
| Type | Master / Variant | 主图集还是变体图集 | 常规用 Master,需要多档分辨率才建 Variant |
| Include in Build | 勾选 / 不勾 | 是否随包烘焙进构建产物 | 常驻资源勾上,走资源包的下发的不勾 |
| Allow Rotation | 勾选 / 不勾 | 允许非正方形精灵旋转 90 度提高填充率 | 纯 UI 图标可开,九宫格、Tilemap 一律关 |
| Tight Packing | 勾选 / 不勾 | 按 alpha 轮廓裁剪后紧密排列 | 不透明大图可开,带渐变边缘的图关掉 |
| Padding | 整数像素 | 每个精灵四周留出的空白边距 | 无 Mipmap 取 2~4,有 Mipmap 取 4~8 |
| Read/Write Enabled | 勾选 / 不勾 | 在 CPU 侧保留一份可读写副本 | 一律不勾,除非运行时真要改像素 |
| Generate Mip Maps | 勾选 / 不勾 | 生成多级渐远纹理 | UI 图集关闭,3D 场景里的 Sprite 才考虑开 |
| sRGB | 勾选 / 不勾 | 按 sRGB 色彩空间解释纹理 | 颜色纹理勾上,数据类纹理(遮罩、噪声)关掉 |
| Filter Mode | Point / Bilinear / Trilinear | 采样过滤方式 | UI 用 Bilinear,像素风才用 Point |
| Compression | None / Low / Normal / High Quality | 纹理压缩等级 | 移动端用 Normal 起步,Q 版大图可以用 HQ |
| Max Texture Size | 32 ~ 8192 | 单张图集纹理的尺寸上限 | 移动端 2048,高端机可以放到 4096 |
| Objects for Packing | 精灵 / 文件夹列表 | 声明哪些资源进入这本图集 | 拖文件夹最省事,但要注意递归范围 |
Allow Rotation 这一项值得单独说。它的原理是把宽高比悬殊的非正方形精灵旋转 90 度再塞进图集,能显著提升填充率,尤其是在图标数量多、形状都很扁的情况下。但它有两个反效果:一是所有 Sprite 的 UV 都变了,如果代码里有人手动算 UV 或者用 Material 传了自定义 UV 偏移,会直接错乱;二是它跟九宫格和 Tilemap 天然冲突,因为这两类东西对朝向有硬性假设。我的习惯是:专门给“纯图标、不参与九宫格”的图集开旋转,其余的全部关掉。
Tight Packing 的道理类似。开启后 Unity 会按精灵的 alpha 轮廓裁掉透明区域,把可见像素紧凑排布,图集面积能小不少。但它会引入采样溢出的风险,尤其是那些边缘有半透明渐变的图,缩放时容易采到隔壁精灵的像素,出现一条细线或色边。如果开了 Tight Packing,Padding 一定要给足,否则边缘问题必然出现。
2.2 Padding、Max Texture Size 和压缩格式怎么定
Padding 的作用是在每个精灵四周人为加一圈空白,防止采样时越界。为什么需要这圈空白?因为纹理采样用的是双线性插值,当 UV 落在边缘时,硬件会同时取相邻四个像素做加权,如果隔壁就是另一张精灵的像素,那这个加权结果就是脏的。加了 Padding 之后,采到的就是空白(或者说是透明的 Padding 区),视觉上就正常了。
Padding 取多少,我的经验是按采样方式和 Mipmap 决定:用 Bilinear 且无 Mipmap,取 2 就够,取 4 更保险;开了 Mipmap 就要往上加,因为低层级的 Mipmap 本身是降采样得到的,采样半径更大,越界概率更高,通常取 4~8。如果画面里有明显的缩放动画(比如 UI 从 0.5 缩放到 1.5),我会直接把 Padding 拉到 8,宁可多占点空间也不要边缘发脏。
Max Texture Size 是最容易被忽略的一项。图集的总像素面积是所有精灵面积之和,带上 Padding 和排列间隙,实际占地面积通常是理论值的 1.2 到 1.5 倍。举个具体的例子:假设你的图集里有 60 张 128×128 的图标,理论面积是 60 × 16384 ≈ 98 万像素,考虑 1.3 倍的排布损耗后大约 128 万像素,开根号约 1130,所以一张 2048 的图集完全装得下。但如果图标数量翻到 200 张,面积就是 327 万像素以上,2048×2048 只有 419 万像素,理论上勉强够,实际排列损耗一上来就会溢出。
一旦超出上限,不同版本的表现不太一致:有的版本会自动分页生成多张纹理,有的版本会直接丢精灵或者只给一条警告。我踩过的坑是包出到真机后发现有几个图标是空白,回头翻构建日志才看到容量不足的提示。所以后面那套工具里,我会强制做一次“打包后可打包对象数量 vs 实际精灵数量”的对比校验,数字对不上就直接失败退出,不让它混进包里。
压缩格式这一项要结合平台来看。移动端主流是 ETC2(Android)和 ASTC(iOS 与新一点的 Android),PC 端用 DXT/BC 系列。Sprite Atlas 的 Inspector 底部有平台覆盖设置,可以针对每个平台单独配。我的建议是:默认面板只作为兜底,Android 和 iOS 一定显式覆盖。ASTC 的块大小选 6×6 是比较平衡的选择,4×4 质量更好但内存和包体都上去,8×8 更省但色块感明显。需要注意的是,不同平台支持的压缩格式不一样,选了一个平台不支持的格式,Unity 会静默回退到未压缩,这时候图集内存会直接翻好几倍,必须靠真机 Profiler 去确认。
2.3 Variant 变体:一套资源做多档分辨率
Variant 是 Sprite Atlas 里最容易被误解的功能。它的设计目标是:你只维护一套高分辨率母图集,然后派生出若干个低分辨率变体,运行时根据设备性能决定用哪一档。比如母图集是 2048,派生一个 Scale 0.5 的变体就是 1024,低端机上加载 1024 那一版,内存直接省掉四分之三。
创建方式是把 Type 改成 Variant,然后在 Master Atlas 字段里指到主图集上,Scale 填一个 0 到 1 之间的值。这里有几个必须注意的点:第一,变体的 Scale 只支持 2 的幂次分数,比如 1、0.5、0.25,填 0.7 会被拒绝或者被规整;第二,变体本身不包含任何 Objects for Packing,它的内容完全来自母图集;第三,母图集和变体必须同时参与打包,否则变体会拿到一份空的引用,运行时请求会失败。
运行时怎么用变体?靠的是资源加载路径。你给母图集定义一个名字,变体是另一个名字,代码里根据画质档位决定从哪个路径加载。这里有个细节:变体加载出来之后,它内部的精灵名字和母图集是一致的,所以业务代码不需要区分,只管通过 GetSprite 拿就行。
我用变体的真实场景是低端机降级:高端机加载 1.0 的图集,中端机加载 0.5,低端机加载 0.25。实测在中低端安卓机上,UI 图集内存能压下来一大截,代价是图标会有轻微模糊,这个取舍在大多数休闲游戏里完全可接受。
3. 运行时代码接口与晚绑定机制
3.1 SpriteAtlas 相关 API 清单
先列一下实际用得上的接口,避免大家翻文档翻半天:
| API | 命名空间 | 用途 |
|---|---|---|
| SpriteAtlas.GetSprite(string) | UnityEngine.U2D | 按精灵名从图集里取 Sprite |
| SpriteAtlas.GetSprites(Sprite[]) | UnityEngine.U2D | 批量填充,减少逐个查询的开销 |
| SpriteAtlas.spriteCount | UnityEngine.U2D | 图集内实际打包的精灵数量,可用于校验 |
| SpriteAtlas.CanBindTo(Sprite) | UnityEngine.U2D | 判断某个 Sprite 是否属于本图集 |
| SpriteAtlasManager.atlasRequested | UnityEngine.U2D | 晚绑定回调,图集未绑定时触发 |
| SpriteAtlasManager.atlasRegistered | UnityEngine.U2D | 图集注册完成回调 |
| SpriteAtlasUtility.PackAtlases | UnityEditor.U2D | 编辑器侧主动打包图集 |
| SpriteAtlasExtensions.GetPackingSettings | UnityEditor.U2D | 读取打包参数 |
| SpriteAtlasExtensions.GetPackables | UnityEditor.U2D | 读取图集内声明的资源列表 |
这里有个使用上的要点:GetSprite返回的 Sprite 持有的是图集大纹理的引用,你只要在场景里挂了一个来自图集的 Sprite,整张大图就会被加载进内存。所以不要“为了拿一个小图标”而去加载一本大图集,图集的划分粒度要跟使用场景匹配。之前遇到过有人把所有 UI 图都塞进一本 4096 的图集,结果登录界面一出来就加载了几十 MB 的纹理,这类问题靠代码是救不回来的,只能靠图集划分方案解决。
GetSprites的批量接口在列表类 UI 里很有用,比如背包一次性要刷 40 个格子,用批量接口能少很多次内部查找。不过要注意它的入参是Sprite[],需要预先分配好数组大小,返回的是实际填充数量,不是填满整个数组。
3.2 atlasRequested 的正确写法
晚绑定解决的是这样一个问题:图集内容不随包内置,而是运行时按需从资源系统加载。这种模式在中重度项目里很常见,因为可以显著压缩首包体积。它的机制是——当某个 Sprite 需要绑定到图集但图集还没加载时,Unity 会抛出atlasRequested回调,把这个图集的名字和回调函数交给你,你在自己的资源系统里加载完,再把结果回传。
using System; using UnityEngine; using UnityEngine.U2D; public class AtlasLateBinding : MonoBehaviour { [SerializeField] private string atlasRoot = "UI/Atlas/"; private void OnEnable() { SpriteAtlasManager.atlasRequested += OnAtlasRequested; SpriteAtlasManager.atlasRegistered += OnAtlasRegistered; } private void OnDisable() { SpriteAtlasManager.atlasRequested -= OnAtlasRequested; SpriteAtlasManager.atlasRegistered -= OnAtlasRegistered; } private void OnAtlasRequested(string tag, Action<SpriteAtlas> callback) { // tag 就是图集资产的名称,不同版本的传参语义略有差异 var path = atlasRoot + tag; var atlas = Resources.Load<SpriteAtlas>(path); if (atlas == null) { Debug.LogError($"[Atlas] 晚绑定失败,未找到图集: {path}"); return; } callback(atlas); } private void OnAtlasRegistered(SpriteAtlas atlas) { Debug.Log($"[Atlas] 图集已注册: {atlas.name}, 精灵数 {atlas.spriteCount}"); } }这段代码有几个坑要提醒。第一,回调里必须调用 callback,即使加载失败也要想清楚策略——直接 return 不调用的话,那个 Sprite 会一直处于未绑定状态,表现为白块或者错误图标,而且不会报错,排查起来很痛苦。第二,回调是同步语义的,如果你用的是异步资源系统,需要把 callback 存起来,加载完再回调,千万别在异步回调里捕获后忘了调用。第三,如果同一个图集被请求多次,记得做一层缓存,避免重复加载同一份资源。
还有个经常被忽略的点:晚绑定只在图集没被内置、也没有被其他方式绑定的情况下才会触发。如果你在 Sprite Atlas 上勾了 Include in Build,那它已经随包烘焙了,晚绑定回调根本不会来。所以“内置”和“晚绑定”是两条互斥的路线,混着用会出现“有的图集走回调、有的不走”的诡异现象,调试的时候很容易怀疑人生。
3.3 业务层替换 Sprite 的实战写法
除了加载图集,业务里更常见的是拿图集里的 Sprite 去替换 UI 上的图片。下面这段是我常用的封装,核心是把图集名和精灵名做一个轻量缓存,避免每帧去查:
using System.Collections.Generic; using UnityEngine; using UnityEngine.U2D; using UnityEngine.UI; public static class AtlasSpriteCache { private static readonly Dictionary<string, SpriteAtlas> Atlases = new Dictionary<string, SpriteAtlas>(); private static readonly Dictionary<string, Sprite> Sprites = new Dictionary<string, Sprite>(); public static Sprite Get(string atlasPath, string spriteName) { var key = atlasPath + "/" + spriteName; if (Sprites.TryGetValue(key, out var cached) && cached != null) return cached; if (!Atlases.TryGetValue(atlasPath, out var atlas) || atlas == null) { atlas = Resources.Load<SpriteAtlas>(atlasPath); if (atlas == null) { Debug.LogError($"[Atlas] 图集加载失败: {atlasPath}"); return null; } Atlases[atlasPath] = atlas; } var sprite = atlas.GetSprite(spriteName); if (sprite == null) Debug.LogError($"[Atlas] 精灵不存在: {atlasPath} -> {spriteName}"); Sprites[key] = sprite; return sprite; } public static void SetImage(Image image, string atlasPath, string spriteName) { var sprite = Get(atlasPath, spriteName); if (sprite != null) image.sprite = sprite; } public static void Clear() { Atlases.Clear(); Sprites.Clear(); } }缓存这一层不是可有可无的优化。GetSprite内部会做一次名称查找,如果在滚动列表里每帧调用,开销会累积得很难看。另外缓存里存了 Sprite 引用,也就等于持有了图集纹理的引用,所以在切换大场景时记得调一次 Clear,否则旧图集永远不会被卸载,内存会一直涨。这个坑我在一个长线运营的项目里踩过一次,表现是每次进新关卡内存涨十几 MB 不回落,查了半天才发现是缓存字典没有清。
还有一个细节:图集里的精灵名默认是纹理的文件名(不含扩展名)。如果美术改了文件名,代码里的名字就失效了,运行时表现为图标不显示。稳妥的做法是在打包工具里导出一份“图集名-精灵名”清单,和代码里的常量做一次比对,这样改名导致的问题在 CI 阶段就能被发现,而不是等到玩家截图反馈。
4. 一键自动打包图集工具的设计与实现
4.1 设计思路:目录约定 + 幂等 + 可校验
工具的目标很明确:把“手工点 Pack Preview、肉眼确认、忘了打包”这套流程,换成一个按钮或者一条命令就能跑完的动作,而且同一个输入必须得到同一个输出。要做到这一点,我定了三条规则。
第一条是目录约定。约定固定目录下的每个子文件夹对应一本图集,图集名等于文件夹名,图集文件统一放在指定位置。这样一来,谁新增了图标,只需要丢进对应文件夹,不需要去 Inspector 上手动拖。工具的职责就是扫描目录、生成或更新 .spriteatlas 资产、把目录内容登记进去。
第二条是幂等。工具每次运行都先清空图集的 Objects for Packing,再按当前目录内容重新登记,然后统一应用一套打包参数。这样避免了“手工加了个别资源、下次工具跑完又被覆盖”的混乱。同时参数不写在工具代码里硬编码死,而是集中在一个配置常量区,方便不同项目调整。
第三条是可校验。工具跑完后必须给出报告:处理了几本图集、每本登记了多少资源、实际打包出多少精灵、耗时多少、有没有资源被两本图集重复包含。数字对不上就报错并返回非零退出码,这样接到 CI 上能直接拦住问题包。第三条其实是这套工具真正的价值所在,光是“一键打包”并不稀奇,难的是打包之后你知道结果是对的。
4.2 编辑器窗口与核心实现代码
下面这份代码放在Assets/Editor/目录或者带 Editor 平台限定的 asmdef 下。它包含扫描、生成、配置、打包、校验几个部分,我拆开讲。
using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using UnityEditor; using UnityEditor.U2D; using UnityEngine; using UnityEngine.U2D; using Debug = UnityEngine.Debug; namespace AtlasToolkit.Editor { public static class AtlasBuilder { // 约定:所有图集资产放在这个目录下 private const string AtlasAssetDir = "Assets/UI/Atlas"; // 约定:每个子文件夹一本图集,源图放在这里 private const string SourceRootDir = "Assets/UI/AtlasSource"; // 打包参数集中配置,便于不同项目调整 private const int Padding = 4; private const bool EnableRotation = false; private const bool EnableTightPacking = false; private const int MaxTextureSize = 2048; [MenuItem("Tools/Atlas Toolkit/一键打包全量图集 %#p")] public static void PackAllFromMenu() { var ok = Run(); // 编辑器下只提示,不终止 Unity EditorUtility.DisplayDialog("图集打包", ok ? "打包完成,请看 Console 报告" : "打包失败,请看 Console", "好"); } // 供命令行调用:-executeMethod AtlasToolkit.Editor.AtlasBuilder.PackAllFromCLI public static void PackAllFromCLI() { var ok = Run(); if (!ok) { EditorApplication.Exit(1); } } private static bool Run() { var watcher = Stopwatch.StartNew(); var sourceFolders = CollectSourceFolders(); if (sourceFolders.Count == 0) { Debug.LogError($"[Atlas] 未在 {SourceRootDir} 下找到任何子文件夹"); return false; } var atlases = new List<SpriteAtlas>(); var registered = new Dictionary<string, int>(); foreach (var folder in sourceFolders) { var atlasName = Path.GetFileName(folder); var atlas = SyncAtlasAsset(atlasName, folder, out var count); if (atlas == null) return false; atlases.Add(atlas); registered[atlasName] = count; } // 先校验重复包含,避免把问题带到打包阶段 if (CheckDuplicatedSprites(atlases)) return false; ApplySettings(atlases); AssetDatabase.SaveAssets(); var target = EditorUserBuildSettings.activeBuildTarget; try { #if UNITY_2020_1_OR_NEWER SpriteAtlasUtility.PackAtlases(atlases.ToArray(), target, false); #else SpriteAtlasUtility.PackAtlases(atlases.ToArray(), target); #endif } catch (Exception e) { Debug.LogError($"[Atlas] 打包抛出异常: {e}"); return false; } watcher.Stop(); return Report(atlases, registered, watcher.ElapsedMilliseconds); } private static List<string> CollectSourceFolders() { var result = new List<string>(); if (!Directory.Exists(SourceRootDir)) return result; foreach (var dir in Directory.GetDirectories(SourceRootDir)) { // Unity 侧路径统一用正斜杠 var unityPath = dir.Replace('\\', '/'); if (AssetDatabase.IsValidFolder(unityPath)) result.Add(unityPath); } result.Sort(StringComparer.Ordinal); return result; } // 后续方法在下一节继续 } }关于SpriteAtlasUtility.PackAtlases的重载,不同版本差异挺大:早期只有两参数版本,2020 之后多了一个控制 GPU 解压行为的布尔参数。我用了条件编译来兼容,如果你用的版本编译不过,直接把#if那段改成两参数版本就行。这也是我在前面反复强调“以你实际版本 API 为准”的原因,编辑器 API 跨版本变动很常见。
SyncAtlasAsset是核心逻辑,它负责把目录内容和图集资产对齐:
private static SpriteAtlas SyncAtlasAsset(string atlasName, string sourceFolder, out int spriteCount) { spriteCount = 0; if (!Directory.Exists(AtlasAssetDir)) Directory.CreateDirectory(AtlasAssetDir); var atlasPath = $"{AtlasAssetDir}/{atlasName}.spriteatlas"; // 1. 取或建图集资产 var asset = AssetDatabase.LoadAssetAtPath<SpriteAtlasAsset>(atlasPath); if (asset == null) { asset = new SpriteAtlasAsset(); SpriteAtlasAsset.Save(asset, atlasPath); AssetDatabase.ImportAsset(atlasPath, ImportAssetOptions.ForceUpdate); asset = AssetDatabase.LoadAssetAtPath<SpriteAtlasAsset>(atlasPath); if (asset == null) { Debug.LogError($"[Atlas] 创建图集失败: {atlasPath}"); return null; } } // 2. 收集目录下的所有 Sprite 资源 var sprites = new List<UnityEngine.Object>(); foreach (var guid in AssetDatabase.FindAssets("t:Sprite", new[] { sourceFolder })) { var path = AssetDatabase.GUIDToAssetPath(guid); var obj = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(path); if (obj != null) sprites.Add(obj); } spriteCount = sprites.Count; if (spriteCount == 0) Debug.LogWarning($"[Atlas] {atlasName} 目录下没有可打包的 Sprite: {sourceFolder}"); // 3. 幂等:先清空再登记 var old = asset.GetPackables(); if (old != null && old.Length > 0) asset.Remove(old); if (sprites.Count > 0) asset.Add(sprites.ToArray()); SpriteAtlasAsset.Save(asset, atlasPath); AssetDatabase.ImportAsset(atlasPath, ImportAssetOptions.ForceUpdate); return AssetDatabase.LoadAssetAtPath<SpriteAtlas>(atlasPath); }注意这里我是逐个 Sprite 登记,而不是直接拖文件夹。直接拖文件夹虽然省事,但GetPackables返回的是一堆文件夹对象,后续做重复校验时还得递归展开,而且文件夹里混进了非 Sprite 资源(比如 psd 源文件、说明文档)也会被一起算进去。逐个登记虽然多一些遍历开销,但结果是确定的、可校验的。
参数应用那一块:
private static void ApplySettings(List<SpriteAtlas> atlases) { foreach (var atlas in atlases) { var packing = atlas.GetPackingSettings(); packing.padding = Padding; packing.enableRotation = EnableRotation; packing.enableTightPacking = EnableTightPacking; atlas.SetPackingSettings(packing); var tex = atlas.GetTextureSettings(); tex.readable = false; tex.generateMipMaps = false; tex.sRGB = true; tex.filterMode = FilterMode.Bilinear; atlas.SetTextureSettings(tex); // 平台覆盖:默认兜底之外,显式配 Android 与 iOS ApplyPlatform(atlas, "Android", TextureImporterFormat.ETC2_RGBA8); ApplyPlatform(atlas, "iPhone", TextureImporterFormat.ASTC_6x6); } } private static void ApplyPlatform(SpriteAtlas atlas, string platform, TextureImporterFormat format) { var ps = atlas.GetPlatformSettings(platform); ps.overridden = true; ps.maxTextureSize = MaxTextureSize; ps.textureCompression = TextureImporterCompression.CompressedHQ; ps.format = format; atlas.SetPlatformSettings(ps); }平台名用的是字符串,比如 "Android"、"iPhone"、"WebGL"、"Standalone",不同版本支持的平台名集合略有差异,写错的话GetPlatformSettings会返回默认值而不报错。所以我建议在工具里加一句日志,把每个平台覆盖后的格式打出来,或者干脆在打包后读一次确认,别默认它一定生效了。
4.3 打包后的校验报告与命令行接入
校验部分是我最看重的,它把“图集有没有真的打进去”这个模糊问题变成了具体数字:
private static bool CheckDuplicatedSprites(List<SpriteAtlas> atlases) { var owner = new Dictionary<string, string>(); var ok = true; foreach (var atlas in atlases) { var packables = atlas.GetPackables(); if (packables == null) continue; foreach (var obj in packables) { var path = AssetDatabase.GetAssetPath(obj); if (string.IsNullOrEmpty(path)) continue; if (owner.TryGetValue(path, out var exist)) { Debug.LogError($"[Atlas] 资源被重复包含: {path} 同时属于 {exist} 和 {atlas.name}"); ok = false; } else { owner[path] = atlas.name; } } } return !ok ? false : true; } private static bool Report(List<SpriteAtlas> atlases, Dictionary<string, int> registered, long ms) { var ok = true; Debug.Log($"[Atlas] ===== 图集打包报告 ({ms} ms) ====="); foreach (var atlas in atlases) { var expect = registered.TryGetValue(atlas.name, out var c) ? c : 0; var actual = atlas.spriteCount; var line = $"[Atlas] {atlas.name}: 登记 {expect} / 打包 {actual}"; if (actual < expect) { line += " <-- 数量不一致,检查纹理导入类型和容量上限"; ok = false; } Debug.Log(line); Debug.Log($"[Atlas] 路径: {AssetDatabase.GetAssetPath(atlas)}"); } Debug.Log("[Atlas] ===== 报告结束 ====="); return ok; }atlas.spriteCount只有在图集真正打包完成后才有值,如果你的图集没被打包,它会返回 0——这本身就是一个很好的探针。数量不一致的常见原因有三个:纹理的导入类型不是 Sprite(比如还是 Default),被其他图集抢走了,或者图集容量超限被丢了一部分。这三种情况都能通过这条日志快速定位。
接到 CI 上就是一行命令的事:
# Linux / macOS 构建机 Unity \ -batchmode \ -quit \ -projectPath "$PWD" \ -executeMethod AtlasToolkit.Editor.AtlasBuilder.PackAllFromCLI \ -logFile - # Windows 构建机 Unity.exe -batchmode -quit -projectPath "%CD%" ^ -executeMethod AtlasToolkit.Editor.AtlasBuilder.PackAllFromCLI ^ -logFile -这里有个必须做的动作:打包失败时要让进程返回非零退出码,否则构建机看到的是“命令执行成功”,会继续往下走。前面PackAllFromCLI里的EditorApplication.Exit(1)就是干这个的。另外-logFile -是把日志打到标准输出,方便构建机收集;如果日志量大,建议改成写文件再上传,不然构建日志会被刷屏。
再补一个实用的小功能:给菜单加个快捷键。上面代码里的%#p是 Ctrl/Cmd + Shift + P,改图之后随手按一下就能重打包,比在菜单里翻半天快得多。这类小便利对日常效率的影响比想象中大,用久了就回不去了。
5. 常见问题与排查速查表
5.1 图集打了但 DrawCall 没降,怎么查
这是最高频的问题,八成不是图集本身的问题。排查顺序我一般固定成四步。第一步,用 Frame Debugger 看当前帧的批次,找到那几次断开的 DrawCall,看它的“为什么不能合批”提示,Unity 会直接告诉你是纹理不同、材质不同还是因为被 Mask 打断。第二步,确认这些 Sprite 是不是真的在图集里——选中 Sprite 资源,看它的 Inspector 上有没有指向某个图集的引用,或者在编辑器里用图集资产的 Pack Preview 看。第三步,检查 UI 层级顺序,看是不是不同图集的图片交替排列。第四步,检查有没有 Image 被设置了不同的 Material 或者被 Mask 包住。
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 部分图不显示 | 精灵名改了 / 图集没打包 / 容量超限 | 对比工具报告里的登记数与打包数 |
| DrawCall 只降了一点 | 图集划分太碎 / 层级顺序交错 | 按界面出现位置重新划分图集 |
| 图集在编辑器正常,真机空白 | 包内图集未重建 / 平台覆盖格式不支持 | 构建前跑一次全量打包,真机确认格式 |
| 图标边缘有细线 | Padding 不足 / Tight Packing 边缘溢出 | 加大 Padding,关掉 Tight Packing |
| 内存莫名翻倍 | Read/Write 被勾选 / Mipmap 被开启 | 两者一律关掉再测一次 |
| 图集变大好几倍 | 压缩格式回退到未压缩 | 通过平台覆盖显式指定格式并真机验证 |
补一个很多人没意识到的点:Canvas 的拆分也会影响合批。如果你的某个 UI 元素勾了 Override Sorting,或者处在一个子 Canvas 下,它就会被单独拆成一批,跟图集没关系。所以看到 DrawCall 数字不对时,先确认这个界面的 Canvas 结构,别一上来就怀疑图集。
5.2 内存、变体和重复资源这几个隐形坑
Read/Write Enabled 是我见过最容易被随手勾上的选项。勾上之后,纹理除了 GPU 侧的一份,CPU 侧还会再保留一份可读写的副本,内存基本就是双份。只有在你真的要在运行时读像素、做截图、做动态贴花的时候才需要它。UI 图集上勾这个,纯属浪费。
Mipmap 的代价是额外的约 33% 内存和更大的包体。UI 图集里绝大部分内容都是 1:1 显示的,用不到 Mipmap,所以默认关掉。唯一例外是那些会在 3D 场景里随镜头远近变化的 Sprite,比如地面上的标记,那种情况才需要开。
重复资源的问题更隐蔽:同一张图被两本图集都登记了,结果就是同一份像素被烘焙两次,内存直接翻倍,而且因为两本图集的纹理不同,还会额外打断合批。这种问题在多人协作的项目里特别容易出现,所以我在工具里加了重复检查,直接让构建失败。发现的时机越早越好,等到真机上才发现就麻烦了。
变体那边另有一个坑:变体的 Scale 只支持 2 的幂次分数,你填 0.7 它不会报错,但实际效果跟你想的不一样。而且变体必须和母图集一起打包,只打包变体的话,它拿到的引用是空的。稳妥做法是把母图集和所有变体放在同一个目录下,工具一次性处理完。
5.3 小游戏与 WebGL 平台的特殊注意
小游戏平台和 WebGL 平台的图集处理有几个特有的地方。第一是纹理压缩格式,这些平台通常不支持 ASTC 和 ETC2,你在 Sprite Atlas 上配的这些格式到了那个平台可能被忽略,需要用平台特定的纹理转换流程处理。所以图集打包时传入的 target 一定要是当前真实的构建目标,别用默认值——前面工具代码里用的EditorUserBuildSettings.activeBuildTarget就是这个意思,切平台构建时会自动带上正确的目标。
第二是图集资源的加载方式。走资源包下发的项目,图集本身要先能加载到,晚绑定回调才会正常触发。如果图集和精灵的加载顺序错乱,就会看到精灵先显示成白块、之后才刷出来的现象。我的做法是在进入一个模块之前,先把该模块需要的图集批量加载完,再打开界面,避免中途出现“先白后好”的闪烁。
第三是图集纹理的内存回收。这些平台的运行环境对内存更敏感,图集纹理一旦被 Sprite 引用住就不会释放。前面提过的缓存字典、静态引用、事件回调里持有的 Sprite,都会让图集纹理一直留在内存里。所以切场景的时候,我会显式把图集缓存清一遍,并调用资源系统的卸载接口,把这个界面对应的图集引用计数降下去。
最后说一个观念上的调整:不要试图用一本超大图集解决所有问题。图集越大,加载粒度越粗,首屏要加载的东西就越多。按界面模块拆成几本中等大小的图集,配合按需加载,才是长期可持续的做法。我见过一个项目把所有 UI 图塞进一本 4096 图集,结果是启动后光 UI 纹理就吃掉几十 MB,后来拆成六本才把内存压回合理区间。
6. 长期维护上我自己的几条经验
图集工具真正的价值不在于省下点击“打包”按钮的那几秒钟,而在于让图集这个环节变得可预测。我在项目里坚持的一个习惯是:图集只允许通过工具生成,任何人不得手工去改 .spriteatlas 的 Objects for Packing。原因很简单,手工改的东西没有记录、也容易被下一次工具运行覆盖,一旦出问题根本说不清是哪个环节导致的。为了配合这条规则,工具里的目录约定必须足够简单直白,简单到“把图丢进文件夹”就完事,否则大家很快就会绕开工具。
第二件事是把打包动作接进提交前的检查或者 CI。我现在的做法是每次构建前强制跑一次全量打包,报告里出现数量不一致就直接失败。这样做的成本只是构建时间多几十秒,收益是彻底消灭了“编辑器里图是对的、包里是旧的”这类最难查的问题。多花几十秒换一个稳定的包,这笔账很划算。
第三件事是给图集资产加一个命名和目录规范,并且在评审时把关。图集划分其实是个架构决策,不是美术顺手拖一拖就能定下来的事情。我的建议是按“同时出现在屏幕上的资源”来分,而不是按“图片长得像不像”来分。同一个界面上会同时出现的图标,尽量放在同一本图集里;永远不会同时出现的,拆开反而更好,因为单本图集更小、加载更快、内存峰值更低。
至于工具本身,其实可以继续往后扩展,比如加一个“未被任何图集引用的散图扫描”,把那些被 UI 用到但还躺在外面的 Sprite 揪出来;或者做一个“图集容量预估”,在登记资源的时候就算出大致面积,提前预警哪本图集快超上限了。这两个功能都不难写,加起来也就一两百行,但对日常开发的体验提升很明显。我自己是在被容量超限坑过一次之后才补上预估的,那次的代价是包发出去之后才发现有几个图标空白。