news 2026/9/29 12:56:19

Unity图集优化:DrawCall、Variant与自动打包

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity图集优化:DrawCall、Variant与自动打包

图集(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 版本字段位置会有微调,但语义是一致的。

属性可选值实际作用我一般怎么设
TypeMaster / 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 ModePoint / Bilinear / Trilinear采样过滤方式UI 用 Bilinear,像素风才用 Point
CompressionNone / Low / Normal / High Quality纹理压缩等级移动端用 Normal 起步,Q 版大图可以用 HQ
Max Texture Size32 ~ 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.spriteCountUnityEngine.U2D图集内实际打包的精灵数量,可用于校验
SpriteAtlas.CanBindTo(Sprite)UnityEngine.U2D判断某个 Sprite 是否属于本图集
SpriteAtlasManager.atlasRequestedUnityEngine.U2D晚绑定回调,图集未绑定时触发
SpriteAtlasManager.atlasRegisteredUnityEngine.U2D图集注册完成回调
SpriteAtlasUtility.PackAtlasesUnityEditor.U2D编辑器侧主动打包图集
SpriteAtlasExtensions.GetPackingSettingsUnityEditor.U2D读取打包参数
SpriteAtlasExtensions.GetPackablesUnityEditor.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 揪出来;或者做一个“图集容量预估”,在登记资源的时候就算出大致面积,提前预警哪本图集快超上限了。这两个功能都不难写,加起来也就一两百行,但对日常开发的体验提升很明显。我自己是在被容量超限坑过一次之后才补上预估的,那次的代价是包发出去之后才发现有几个图标空白。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 12:56:02

Linux 搭建 Samba 服务器实现跨平台文件共享

&#xfeff;前言NFS 好用&#xff0c;但Windows 访问 NFS 极其麻烦&#xff08;要装客户端、要配身份映射、中文还容易乱码&#xff09;。如果共享目录需要 Windows 同事直接双击打开&#xff0c;那就得上 Samba。Samba 是 SMB/CIFS 协议的开源实现&#xff0c;Windows 资源管…

作者头像 李华
网站建设 2026/9/29 12:54:36

本地缓存与分布式缓存选型:Caffeine与Redis两级缓存实战

简介&#xff1a;本地缓存与分布式缓存&#xff0c;是后端架构设计里的常见选择难题。这份PPT用一整套幻灯片讲清两种缓存机制&#xff1a;先交代缓存以空间换时间、应对高并发读压力的本质&#xff0c;再逐一对比本地缓存的命中速度快、适合小数据量存储&#xff0c;但存在集群…

作者头像 李华
网站建设 2026/9/29 12:52:56

MDT部署批量安装操作系统:从PXE引导到任务序列的完整指南

简介&#xff1a;一份面向服务器运维与 IT 管理者的 MDT 批量部署实操文档。内容围绕 MDT2013 协作活动目录、WDS、DHCP、ADK 完成 Windows 批量安装&#xff0c;先说明各组件在用户认证、IP 分配、网络启动和映像分发中的分工&#xff0c;再按步骤梳理部署服务器搭建、共享目录…

作者头像 李华
网站建设 2026/9/29 12:41:39

2026年AI搜索优化行业现状与正规服务商选择指南

为什么选正规AI搜索优化服务商?你大概率会踩这4个坑在生成式AI快速普及的当下&#xff0c;通过AI搜索获取企业信息已经成为大多数用户的习惯。不管是找财税服务、口腔医疗&#xff0c;还是采购工业零部件、拓展海外业务&#xff0c;大家都会下意识打开豆包、通义千问、ChatGPT…

作者头像 李华