尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

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

发布时间:2026/9/29 12:56:21

资讯中心
01
ARTICLE

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

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需要多档分辨率才建 VariantInclude in Build勾选 / 不勾是否随包烘焙进构建产物常驻资源勾上走资源包的下发的不勾Allow Rotation勾选 / 不勾允许非正方形精灵旋转 90 度提高填充率纯 UI 图标可开九宫格、Tilemap 一律关Tight Packing勾选 / 不勾按 alpha 轮廓裁剪后紧密排列不透明大图可开带渐变边缘的图关掉Padding整数像素每个精灵四周留出的空白边距无 Mipmap 取 2~4有 Mipmap 取 4~8Read/Write Enabled勾选 / 不勾在 CPU 侧保留一份可读写副本一律不勾除非运行时真要改像素Generate Mip Maps勾选 / 不勾生成多级渐远纹理UI 图集关闭3D 场景里的 Sprite 才考虑开sRGB勾选 / 不勾按 sRGB 色彩空间解释纹理颜色纹理勾上数据类纹理遮罩、噪声关掉Filter ModePoint / Bilinear / Trilinear采样过滤方式UI 用 Bilinear像素风才用 PointCompressionNone / Low / Normal / High Quality纹理压缩等级移动端用 Normal 起步Q 版大图可以用 HQMax Texture Size32 ~ 8192单张图集纹理的尺寸上限移动端 2048高端机可以放到 4096Objects for Packing精灵 / 文件夹列表声明哪些资源进入这本图集拖文件夹最省事但要注意递归范围Allow Rotation 这一项值得单独说。它的原理是把宽高比悬殊的非正方形精灵旋转 90 度再塞进图集能显著提升填充率尤其是在图标数量多、形状都很扁的情况下。但它有两个反效果一是所有 Sprite 的 UV 都变了如果代码里有人手动算 UV 或者用 Material 传了自定义 UV 偏移会直接错乱二是它跟九宫格和 Tilemap 天然冲突因为这两类东西对朝向有硬性假设。我的习惯是专门给“纯图标、不参与九宫格”的图集开旋转其余的全部关掉。Tight Packing 的道理类似。开启后 Unity 会按精灵的 alpha 轮廓裁掉透明区域把可见像素紧凑排布图集面积能小不少。但它会引入采样溢出的风险尤其是那些边缘有半透明渐变的图缩放时容易采到隔壁精灵的像素出现一条细线或色边。如果开了 Tight PackingPadding 一定要给足否则边缘问题必然出现。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 实际精灵数量”的对比校验数字对不上就直接失败退出不让它混进包里。压缩格式这一项要结合平台来看。移动端主流是 ETC2Android和 ASTCiOS 与新一点的 AndroidPC 端用 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按精灵名从图集里取 SpriteSpriteAtlas.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, ActionSpriteAtlas callback) { // tag 就是图集资产的名称不同版本的传参语义略有差异 var path atlasRoot tag; var atlas Resources.LoadSpriteAtlas(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 Dictionarystring, SpriteAtlas Atlases new Dictionarystring, SpriteAtlas(); private static readonly Dictionarystring, Sprite Sprites new Dictionarystring, 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.LoadSpriteAtlas(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 ListSpriteAtlas(); var registered new Dictionarystring, 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 Liststring CollectSourceFolders() { var result new Liststring(); 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.LoadAssetAtPathSpriteAtlasAsset(atlasPath); if (asset null) { asset new SpriteAtlasAsset(); SpriteAtlasAsset.Save(asset, atlasPath); AssetDatabase.ImportAsset(atlasPath, ImportAssetOptions.ForceUpdate); asset AssetDatabase.LoadAssetAtPathSpriteAtlasAsset(atlasPath); if (asset null) { Debug.LogError($[Atlas] 创建图集失败: {atlasPath}); return null; } } // 2. 收集目录下的所有 Sprite 资源 var sprites new ListUnityEngine.Object(); foreach (var guid in AssetDatabase.FindAssets(t:Sprite, new[] { sourceFolder })) { var path AssetDatabase.GUIDToAssetPath(guid); var obj AssetDatabase.LoadAssetAtPathUnityEngine.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.LoadAssetAtPathSpriteAtlas(atlasPath); }注意这里我是逐个 Sprite 登记而不是直接拖文件夹。直接拖文件夹虽然省事但GetPackables返回的是一堆文件夹对象后续做重复校验时还得递归展开而且文件夹里混进了非 Sprite 资源比如 psd 源文件、说明文档也会被一起算进去。逐个登记虽然多一些遍历开销但结果是确定的、可校验的。参数应用那一块private static void ApplySettings(ListSpriteAtlas 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(ListSpriteAtlas atlases) { var owner new Dictionarystring, 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(ListSpriteAtlas atlases, Dictionarystring, 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 揪出来或者做一个“图集容量预估”在登记资源的时候就算出大致面积提前预警哪本图集快超上限了。这两个功能都不难写加起来也就一两百行但对日常开发的体验提升很明显。我自己是在被容量超限坑过一次之后才补上预估的那次的代价是包发出去之后才发现有几个图标空白。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。