ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Unity纹理打包工具开发:MaxRects算法与SpriteAtlas自动化实践

2026/8/9 8:11:56 拓冰建站 浏览量
Unity纹理打包工具开发:MaxRects算法与SpriteAtlas自动化实践

1. 项目概述:为什么我们需要一个自己的纹理打包工具?

在Unity项目里,尤其是UI资源密集的游戏或应用,纹理打包(Texture Packing)或者说图集(Atlas)制作,是绕不开的一环。你可能用过Unity自带的Sprite Atlas,或者TexturePacker这类第三方工具。自带的Sprite Atlas功能强大,但有时在批处理、自定义规则或者与特定美术流程对接时,总觉得不够顺手;而TextureParker这类专业工具虽然效果好,但要么收费,要么在自动化集成、与项目特定资源管理规则(比如Addressables)结合时,存在一些隔阂。

我自己在带项目时,就经常遇到几个痛点:美术同学给过来几百张UI散图,需要按模块自动打包成多个图集,还要兼顾不同平台(Android/iOS/PC)的纹理格式和压缩设置;或者是在使用Addressables进行资源分包时,希望图集的生成和依赖分析能更深度地集成到打包管线里,减少手动操作。网上的网页工具虽然方便,但对于需要保密的内网项目或者要求高度自动化、可定制化的生产环境,一个本地化、可编程、深度集成到Unity Editor的工具就显得非常必要了。

因此,设计并实现一个“高效”的Unity纹理打包工具,核心目标不仅仅是把图片拼在一起。它的“高效”体现在几个层面:对开发者高效,提供清晰的API和编辑器扩展,一键完成复杂任务;对运行时高效,生成的图集布局紧凑,减少纹理空间浪费和Draw Call;对工作流高效,能无缝接入现有的Asset Pipeline,支持批处理、规则化配置和与Addressables等系统的联动。接下来,我就结合自己的实践,拆解一下这样一个工具从设计到实现的核心思路与细节。

2. 核心设计思路与架构选型

2.1 需求拆解:工具要解决哪些具体问题?

在动手写代码之前,先得把需求理清楚。一个高效的纹理打包工具,至少要满足以下核心需求:

  1. 自动化与批处理:能够根据规则(如目录结构、文件名前缀、标签)自动将大量散图分组,并批量生成对应的图集,而不是一张张手动添加。
  2. 高性能打包算法:这是工具的核心。算法需要将一组尺寸各异的矩形(图片)尽可能紧密地排列到一个或多个固定大小或动态计算大小的矩形(图集)中,以最大化空间利用率。这本质上是一个矩形装箱(Rectangle Packing)问题。
  3. 深度编辑器集成:提供友好的Inspector面板进行配置,支持拖拽操作,并能响应Unity的资产导入事件(如PostProcessTexture),在资源导入时自动触发相关打包逻辑。
  4. 灵活的输出配置:支持设置图集的最大尺寸(如2048x2048)、像素格式(RGBA32、RGB24等)、压缩格式(针对不同平台)、是否生成2的幂次方尺寸、是否允许旋转图片以优化空间等。
  5. 生成附属数据:不仅要输出合并后的纹理(如.png或.tga文件),还要生成记录每张子图在图集中位置(UV坐标)、原始尺寸等信息的配置文件(如.spriteatlas文件或自定义的.json/.asset文件)。Unity原生的Sprite Atlas Asset就是干这个的。
  6. 与资源管理系统兼容:特别是与Unity的Addressable Asset System(AAS)兼容。理想情况下,工具生成的图集Asset能方便地标记为Addressable,并且能正确建立散图与图集之间的依赖关系,避免资源冗余。
  7. 可扩展性:打包算法、输出格式、预处理规则(如自动裁剪透明边)等应该设计为可插拔的模块,便于后续优化或适应特殊需求。

2.2 技术方案选型:为什么这么选?

基于以上需求,我们来确定技术方案。

2.2.1 打包算法选型:MaxRects vs. Skyline

矩形装箱算法有很多,在游戏开发领域最常用的是MaxRects算法Skyline算法。TexturePacker等工具也多采用其变种。

  • MaxRects算法:维护一个当前图集中所有空闲矩形的列表。当放入一张新图片时,算法会尝试将其放入一个能容纳它且“浪费”空间最小的空闲矩形中。放入后,该空闲矩形被占用,并可能分裂出新的、更小的空闲矩形。这个算法通常能取得非常高的空间利用率,是业界的首选。
  • Skyline算法:将图集从上到下视为由一条“天际线”分割。天际线由一系列水平线段组成,定义了已占用区域的上边界。放入新图片时,算法沿着天际线寻找合适的位置,可能会尝试多种放置策略(如放在最低点、最佳匹配点等)。它的实现相对简单,但空间利用率有时略低于优化后的MaxRects。

我的选择与理由:为了实现高空间利用率,我选择实现一个MaxRects算法的变种。Unity内部在生成Sprite Atlas时也使用了类似的算法。我们不必从头造轮子,可以借鉴一些成熟的开源实现,但关键是要将其封装成适合Unity Editor环境、可处理Texture2D对象的C#类库。算法的核心是追求在可接受的时间内(对于几百张图片,应在几秒内完成)达到最优的装箱结果。

2.2.2 与Unity的集成方式:EditorWindow与AssetPostprocessor

工具需要以两种主要方式与开发者交互:

  1. 主动式工具窗口(EditorWindow):创建一个类似“Texture Packer Tool”的窗口,允许开发者手动选择文件夹、配置规则、预览布局并执行打包。这是进行一次性批量处理或调试的主要界面。
  2. 响应式资产后处理(AssetPostprocessor):继承AssetPostprocessor,特别是重写OnPostprocessTexture方法。这样,当美术同学导入或修改一张纹理时,可以根据预设的规则(例如,所有放在“Resources/UI/Buttons”下的图片自动归入“UI_Buttons”图集),自动触发图集的更新或重建。这能极大地提升美术工作流的效率。

2.2.3 输出格式:Sprite Atlas Asset vs. 自定义Asset

Unity已经提供了官方的SpriteAtlasAsset类型。使用它有巨大优势:

  • 原生支持:Unity运行时和编辑器完全理解这种格式,SpriteRenderer可以直接引用。
  • 运行时加载与管理:Unity会帮你处理图集的加载、卸载和Sprite的引用。
  • 与Addressables无缝集成SpriteAtlas可以直接标记为Addressable,系统能正确处理其依赖关系。

因此,我们的工具应该以生成和配置Unity的SpriteAtlasAsset为主要目标,而不是自己另搞一套数据格式。我们的价值在于自动化创建和配置这些SpriteAtlas的过程,以及提供更灵活的图片分组、打包规则。

2.2.4 与Addressables的集成

这是体现工具“高效”和“现代”的关键。我们需要确保:

  • 工具生成的SpriteAtlasasset可以被方便地分配到Addressables组中。
  • 在构建Addressables时,工具打包进去的原始散图纹理不应该再被单独包含进构建,否则就失去了打包减少Draw Call和合批的意义。这需要通过正确设置Addressables的依赖分析和打包策略来实现。通常,我们需要确保只有SpriteAtlas是Addressable,而散图不是,并且散图被标记为SpriteAtlas的依赖项。

2.3 整体架构设计

基于以上选型,我们可以勾勒出工具的简易架构:

  1. 配置层(Config):定义打包规则的数据结构,例如PackingRule。包含:匹配路径/标签的规则、输出图集名称、最大尺寸、格式、压缩设置、是否允许旋转等。这些配置可以保存为一个ScriptableObject(如TexturePackerSettings.asset),方便在不同项目间共享和版本管理。
  2. 核心算法层(Packer Core):包含MaxRectsPacker等算法实现类。输入是一组PackingItem(包含Texture2D引用、尺寸、是否允许旋转等信息),输出是PackingResult(包含一个或多个AtlasLayout,每个Layout包含最终纹理尺寸和所有子图的位置信息)。
  3. 资源操作层(Asset Builder):负责具体的Unity资产操作。
    • AtlasBuilder:根据PackingResult,创建新的或更新已有的SpriteAtlasAsset。具体工作包括:创建一个新的Texture2D作为图集纹理,使用Graphics.CopyTextureTexture2D.SetPixels将各个子图的像素数据“画”到正确的位置;创建SpriteAtlas对象,将其Texture属性指向刚创建的图集纹理,并为其添加Sprite对象(这些Sprite可以是从原始纹理创建的,也可以直接使用原始纹理上的Sprite)。
    • DependencyManager:处理与Addressables的集成。在构建前后,可能需要编写脚本(如IPreprocessBuildWithReport)来确保资源依赖关系正确。
  4. 编辑器界面层(Editor UI)
    • TexturePackerWindow:主要的工具窗口,展示配置、提供文件夹拖拽入口、显示打包预览、执行打包按钮。
    • RuleEditor:用于编辑PackingRule的PropertyDrawer或自定义Editor。
    • AutoPackingProcessor:继承自AssetPostprocessor,在资源导入时根据配置自动触发打包。

3. 核心模块实现细节与踩坑实录

3.1 MaxRects算法在Unity中的实现要点

算法本身是纯数据计算,不依赖Unity API。我们定义一个PackingRect结构来代表一个矩形(图片或空闲区域),包含x, y, width, height。算法类MaxRectsPacker的核心方法是Pack

public class MaxRectsPacker { public enum FreeRectChoiceHeuristic { BestShortSideFit, // 最佳短边匹配 BestLongSideFit, // 最佳长边匹配 BestAreaFit, // 最佳面积匹配 // ... 其他策略 } public PackingResult Pack(List<Texture2D> textures, int maxWidth, int maxHeight, int padding, bool allowRotations) { // 1. 初始化:将整个maxWidth*maxHeight区域作为一个空闲矩形加入列表。 List<PackingRect> freeRects = new List<PackingRect> { new PackingRect(0, 0, maxWidth, maxHeight) }; List<PackedRect> packedRects = new List<PackedRect>(); // 2. 将输入纹理按面积从大到小排序(通常大件先放有利于提高利用率)。 var items = textures.OrderByDescending(t => t.width * t.height).ToList(); // 3. 遍历每个item,为其寻找最佳放置位置。 foreach (var tex in items) { PackedRect bestPackedRect = FindBestPosition(tex, freeRects, maxWidth, maxHeight, padding, allowRotations, FreeRectChoiceHeuristic.BestAreaFit); if (bestPackedRect != null) { packedRects.Add(bestPackedRect); // 4. 放置后,从freeRects中移除被占用的矩形,并可能分裂出新的空闲矩形。 PlaceRect(bestPackedRect.rect, freeRects); } else { // 5. 如果当前图集放不下了,需要创建新图集(在新的PackingResult中)。 // 这里涉及多图集逻辑,略。 } } // 6. 返回打包结果。 return new PackingResult(packedRects, CalculateOccupancy(packedRects, maxWidth, maxHeight)); } // ... 其他辅助方法:FindBestPosition, PlaceRect, SplitFreeRect, CalculateOccupancy等 }

实现心得与坑点

  • 排序很重要:先放大的图片,再放小的,这是提高空间利用率的关键经验。逆序(先小后大)通常效果很差。
  • Padding(边距)处理:在计算矩形尺寸和位置时,必须考虑padding。这不仅是视觉上的间隔,更是为了应对纹理采样时可能出现的“ bleed ”问题(即相邻图块的边缘颜色渗入)。我们通常在原始图片尺寸上加上2*padding(左右或上下)作为打包时使用的尺寸。在最终生成图集纹理和计算UV时,坐标也要向内收缩padding对应的像素。
  • 旋转策略:如果允许旋转,在FindBestPosition时,需要分别计算图片原始朝向和旋转90度后两种尺寸的放置得分,选择更优者。这会使计算量翻倍,但能显著提升对细长型图片的打包效率。
  • 多图集生成:当一张图集放不下所有图片时,算法需要能自动开启新的一张图集继续打包。我们的PackingResult需要支持包含多个AtlasLayout

3.2 创建与配置Sprite Atlas Asset

这是与Unity引擎深度交互的部分。我们不能直接修改SpriteAtlas的纹理数据,而是需要先创建好纹理,再赋值给它。

public static class AtlasBuilder { public static SpriteAtlas BuildAtlas(PackingResult.AtlasLayout layout, string atlasName, string savePath) { // 1. 创建图集纹理 Texture2D atlasTexture = new Texture2D(layout.width, layout.height, TextureFormat.RGBA32, false); atlasTexture.name = atlasName; // 初始化为透明 Color[] clearColors = new Color[layout.width * layout.height]; for (int i = 0; i < clearColors.Length; i++) clearColors[i] = Color.clear; atlasTexture.SetPixels(clearColors); atlasTexture.Apply(); // 2. 将每个子图画到图集纹理的对应位置 List<Sprite> spritesInAtlas = new List<Sprite>(); foreach (var packedItem in layout.packedItems) { Texture2D sourceTex = packedItem.sourceTexture; // 注意:需要考虑padding,实际写入的矩形区域是 packedItem.rect 向内收缩padding像素 RectInt targetRect = new RectInt(packedItem.rect.x + padding, packedItem.rect.y + padding, sourceTex.width, sourceTex.height); // 使用Graphics.CopyTexture进行块复制,效率远高于Get/SetPixels Graphics.CopyTexture(sourceTex, 0, 0, 0, 0, sourceTex.width, sourceTex.height, atlasTexture, 0, 0, targetRect.x, targetRect.y); // 3. 为原始纹理创建Sprite(如果还没有的话),并记录其UV信息 Sprite sprite = Sprite.Create(sourceTex, new Rect(0,0,sourceTex.width, sourceTex.height), new Vector2(0.5f, 0.5f)); // 实际上,更常见的做法是直接使用原始纹理上已有的Sprite,这里简化了 spritesInAtlas.Add(sprite); } atlasTexture.Apply(); // 所有CopyTexture操作后,需要Apply // 4. 保存纹理为资产 string texturePath = Path.Combine(savePath, atlasName + ".png"); byte[] pngData = atlasTexture.EncodeToPNG(); File.WriteAllBytes(texturePath, pngData); AssetDatabase.ImportAsset(texturePath); TextureImporter importer = AssetImporter.GetAtPath(texturePath) as TextureImporter; // 配置importer,如设置为Sprite(2D and UI),关闭Mipmaps,设置Pivot等 importer.textureType = TextureImporterType.Sprite; importer.spriteImportMode = SpriteImportMode.Multiple; // 这里需要根据layout.packedItems的信息,设置sprite的rect和pivot,比较复杂,略 importer.SaveAndReimport(); // 5. 创建SpriteAtlas Asset SpriteAtlas spriteAtlas = new SpriteAtlas(); // 设置打包参数 SpriteAtlasPackingSettings packSettings = new SpriteAtlasPackingSettings(); packSettings.padding = padding; packSettings.enableRotation = allowRotations; spriteAtlas.SetPackingSettings(packSettings); // 设置纹理参数 SpriteAtlasTextureSettings textureSettings = new SpriteAtlasTextureSettings(); textureSettings.readable = false; // 运行时通常不需要读写 textureSettings.generateMipMaps = false; spriteAtlas.SetTextureSettings(textureSettings); // 添加Sprite spriteAtlas.Add(spritesInAtlas.ToArray()); // 指定图集纹理 Texture2D savedTexture = AssetDatabase.LoadAssetAtPath<Texture2D>(texturePath); spriteAtlas.SetTextureSettings(new SpriteAtlasTextureSettings()); // 重新设置一下以关联纹理? // 注意:直接设置SpriteAtlas的纹理不是标准做法。更标准的做法是上面通过Add添加Sprite,Unity会自动管理纹理。 // 我们这里相当于“欺骗”Unity,手动提供了纹理。更稳健的做法是利用SpriteAtlas的`BuildTargetSettings`和构建管线。 // 6. 保存SpriteAtlas资产 string atlasAssetPath = Path.Combine(savePath, atlasName + ".spriteatlas"); AssetDatabase.CreateAsset(spriteAtlas, atlasAssetPath); return spriteAtlas; } }

重要提示与深坑:上面示例中手动创建纹理并关联到SpriteAtlas的方法是一种“硬连接”,在简单情况下可能工作,但不是Unity官方推荐或最稳定的方式,尤其是在处理不同平台纹理压缩时。更专业、更可靠的做法是:

  1. 准备好所有需要打包的Sprite资产(散图)。
  2. 创建一个空的SpriteAtlasAsset。
  3. 将这些Sprite拖入或通过代码Add到这个SpriteAtlasObjects列表中。
  4. 配置好SpriteAtlas的各项参数(Padding, Allow Rotation等)。
  5. 调用SpriteAtlasUtility.PackAtlases方法。这个Unity API会接管所有纹理合并、压缩、平台差异化处理等复杂工作,并自动生成最终的图集纹理(通常是一个隐藏的.tex文件)。这是我们工具应该努力集成的方向。我们的“打包”过程,更多是自动化步骤1到4,然后触发步骤5。

3.3 自动化规则引擎与AssetPostprocessor集成

为了让工具智能,我们需要一个规则系统。定义一个PackingRuleScriptableObject:

[CreateAssetMenu(fileName = "PackingRule", menuName = "Tools/Texture Packing Rule")] public class PackingRule : ScriptableObject { public string ruleName; public string filterPattern; // 如 "Assets/Art/UI/*/Button*.png" public string atlasNamePrefix; public int maxAtlasSize = 2048; public int padding = 2; public bool allowRotations = false; public TextureImporterFormat androidFormatOverride; public TextureImporterFormat iosFormatOverride; // 更多规则... }

然后,创建一个AutoPackingProcessor

public class AutoPackingProcessor : AssetPostprocessor { static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths) { foreach (string assetPath in importedAssets) { if (IsTextureAsset(assetPath)) { // 1. 加载所有PackingRule配置 var allRules = LoadAllPackingRules(); // 2. 查找匹配该资源路径的规则 PackingRule matchedRule = allRules.Find(rule => MatchesRule(assetPath, rule)); if (matchedRule != null) { // 3. 延迟调用或加入队列,避免在导入过程中进行复杂的资产操作 EditorApplication.delayCall += () => ProcessTextureForRule(assetPath, matchedRule); } } } } private static void ProcessTextureForRule(string texturePath, PackingRule rule) { // 找到该规则对应的SpriteAtlas asset(或创建新的) SpriteAtlas targetAtlas = FindOrCreateAtlasForRule(rule); // 将纹理对应的Sprite添加到这个Atlas中 Sprite sprite = AssetDatabase.LoadAssetAtPath<Sprite>(texturePath); // 确保纹理已设置为Sprite模式 if (sprite != null && !targetAtlas.CanBindTo(sprite)) { targetAtlas.Add(new UnityEngine.Object[] { sprite }); EditorUtility.SetDirty(targetAtlas); // 可以在这里添加日志或通知 } // 注意:添加后可能需要手动触发一次PackAtlases,或者等待下一次构建时自动打包。 // 更优的做法是收集一批变更后,在合适的时机(如离开编辑器播放模式、手动点击按钮)统一打包。 } }

注意事项

  • 性能考量OnPostprocessAllAssets可能被频繁调用。不要在这里直接执行耗时的打包算法或频繁保存资产。应该将需要处理的纹理路径和规则缓存起来,通过EditorApplication.delayCall或一个定时器/队列在编辑器空闲时批量处理。
  • 循环依赖与死锁:小心处理规则。如果规则A的图集依赖于规则B的图集中的某个Sprite,而处理顺序不当可能导致问题。最好让规则之间独立,或者设计一个依赖关系解析机制。
  • 与版本控制系统协作:自动生成的SpriteAtlas资产和纹理需要纳入版本管理。要确保生成结果是确定性的(即同样的输入永远产生同样的输出),避免不必要的合并冲突。

3.4 与Addressables的协同工作流

这是提升工具实用性的关键。目标:让工具打包生成的SpriteAtlas能够完美融入Addressables资源管理流程。

  1. 标记图集为Addressable:在创建或更新SpriteAtlas资产后,通过代码将其地址(Address)加入Addressables系统。

    using UnityEditor.AddressableAssets; using UnityEditor.AddressableAssets.Settings; public static void MarkAtlasAsAddressable(SpriteAtlas atlas, string address) { var settings = AddressableAssetSettingsDefaultObject.Settings; if (settings == null) return; string atlasPath = AssetDatabase.GetAssetPath(atlas); var entry = settings.CreateOrMoveEntry(AssetDatabase.AssetPathToGUID(atlasPath), settings.DefaultGroup); entry.address = address; // 可以设置标签等其他属性 settings.SetDirty(AddressableAssetSettings.ModificationEvent.EntryModified, entry, true); }
  2. 处理散图依赖:这是核心难点。我们不希望散图也被单独打包进Addressables。需要确保:

    • 散图纹理的Inspector中,不要勾选“Addressable”。
    • 在Addressables Groups窗口,散图不应该出现在任何组里。
    • 但是,Unity的构建管线需要知道SpriteAtlas依赖这些散图。幸运的是,只要散图被正常引用(作为SpriteAtlasObjects列表中的元素),Addressables构建时通常能自动识别这种依赖关系,并只将图集纹理包含在构建中。但为了绝对可靠,有时需要在构建前脚本中,确保散图的Addressable状态被正确清理。
  3. 构建前处理:可以编写一个实现了IPreprocessBuildWithReport接口的类,在构建Addressables之前,运行一次工具的全量打包,并确保所有资源状态正确。

    public class TexturePackerBuildPreprocessor : IPreprocessBuildWithReport { public int callbackOrder => 0; public void OnPreprocessBuild(BuildReport report) { // 1. 执行一次完整的规则匹配与图集重建 TexturePackerCore.ExecuteFullPack(); // 2. 确保所有生成的SpriteAtlas已正确标记为Addressable TexturePackerCore.EnsureAllAtlasesAddressable(); // 3. (可选)清理未被任何图集引用的散图的Addressable标记 CleanupOrphanedTextureAddressables(); } }

4. 编辑器工具界面开发与用户体验优化

一个友好的界面能极大提升工具的使用频率和效率。我们创建一个TexturePackerWindow

4.1 主窗口布局与功能

窗口主要分为几个区域:

  • 规则管理区:列表显示所有已配置的PackingRule,支持创建、删除、启用/禁用规则。
  • 资源预览区:展示被当前选中规则匹配到的所有纹理资源,以缩略图网格形式显示,并可以显示图片尺寸、预估内存等信息。
  • 打包预览区:模拟MaxRects算法的打包结果,以棋盘格或颜色块的形式展示图集布局,直观显示空间利用率。可以切换查看不同图集。
  • 日志与控制台:显示打包过程的日志,如“成功打包X张图片到Y个图集,平均利用率Z%”。
  • 操作按钮:如“扫描资源”、“预览打包”、“执行打包”、“清除缓存”等。

4.2 实现动态预览

预览功能是工具的灵魂。我们需要在EditorWindow的OnGUI中,使用GUI.DrawTextureEditorGUI.DrawTextureAlpha来绘制纹理和模拟的图集布局。

public class TexturePackerWindow : EditorWindow { private PackingResult _currentPreviewResult; private Vector2 _scrollPos; private float _zoomLevel = 1.0f; void OnGUI() { // ... 规则列表、按钮等 // 打包预览区域 if (_currentPreviewResult != null && _currentPreviewResult.atlasLayouts.Count > 0) { _scrollPos = EditorGUILayout.BeginScrollView(_scrollPos, GUILayout.Height(400)); foreach (var layout in _currentPreviewResult.atlasLayouts) { EditorGUILayout.LabelField($"图集: {layout.name} ({layout.width}x{layout.height}), 利用率: {layout.occupancy:P2}"); // 创建一个区域用于绘制图集 Rect atlasRect = GUILayoutUtility.GetRect(layout.width * _zoomLevel, layout.height * _zoomLevel); // 绘制一个背景框 EditorGUI.DrawRect(atlasRect, Color.gray); // 绘制每个打包的矩形块 foreach (var packedItem in layout.packedItems) { Rect itemRect = new Rect( atlasRect.x + packedItem.rect.x * _zoomLevel, atlasRect.y + packedItem.rect.y * _zoomLevel, packedItem.rect.width * _zoomLevel, packedItem.rect.height * _zoomLevel ); // 用不同颜色区分不同图片,或者尝试绘制缩略图(性能考虑) EditorGUI.DrawRect(itemRect, GetHashColor(packedItem.sourceTexture.name)); // 可以在矩形块上绘制文件名(如果空间足够) if (itemRect.width > 30 && itemRect.height > 15) { GUI.Label(new Rect(itemRect.x, itemRect.y, itemRect.width, 20), packedItem.sourceTexture.name, EditorStyles.miniLabel); } } } EditorGUILayout.EndScrollView(); // 缩放控制 EditorGUILayout.BeginHorizontal(); GUILayout.Label("缩放:"); _zoomLevel = EditorGUILayout.Slider(_zoomLevel, 0.1f, 3.0f); EditorGUILayout.EndHorizontal(); } } private Color GetHashColor(string str) { // 一个简单的根据字符串生成颜色的方法 int hash = str.GetHashCode(); return new Color( (hash & 0xFF) / 255.0f, ((hash >> 8) & 0xFF) / 255.0f, ((hash >> 16) & 0xFF) / 255.0f, 0.7f ); } }

4.3 性能优化与用户体验细节

  • 异步操作:打包几百张图片可能耗时数秒。一定要将耗时的打包计算放在后台线程(如使用Task.Run),或者在Editor协程中分帧进行,避免阻塞主线程导致编辑器卡死。在计算时,显示一个进度条(EditorUtility.DisplayProgressBar)是必须的。
  • 缓存机制:如果只是修改了某个不相关的资源,没必要全量重新计算打包。可以基于规则和输入纹理列表的哈希值进行缓存。只有当匹配的纹理集合发生变化时,才重新计算布局。
  • 撤销支持:对于通过工具窗口执行的操作(如应用规则、手动调整),尽量实现Undo.RecordObject来支持Ctrl+Z撤销,提升操作安全性。
  • 错误处理与日志:提供清晰、具体的错误信息。例如,当某张图片尺寸超过最大图集尺寸时,明确指出是哪张图片,并给出建议(如调整规则或分割图片)。

5. 常见问题、排查技巧与进阶优化

在实际使用和开发这类工具的过程中,我遇到了不少坑,这里总结一下。

5.1 打包结果不稳定或空间利用率低

  • 问题:同样的图片集合,两次打包的布局不同,或者利用率时高时低。
  • 排查
    1. 输入顺序:确保输入给打包算法的纹理列表顺序是固定的。如果顺序随机,MaxRects算法的结果可能不同。通常按面积降序排列能获得稳定且较好的结果。
    2. 算法启发式策略:MaxRects有多种选择空闲矩形的策略(BestShortSideFit, BestLongSideFit, BestAreaFit)。不同的策略结果不同。可以提供一个选项让用户选择,或者实现一个多策略尝试并取最优结果的机制。
    3. 随机种子:如果你的算法中涉及任何随机性(例如在某些平局情况下随机选择),请固定随机种子。
  • 优化:实现一个“贪心+回溯”或“模拟退火”的变种。先按默认策略打包,然后尝试交换其中几张小图片的位置,看是否能改善利用率。对于固定内容的图集(如UI图集),可以牺牲一些打包时间换取最高的空间利用率。

5.2 生成的Sprite Atlas在运行时显示为粉色(Missing)

  • 问题:工具生成的图集在编辑器里看着正常,但运行游戏时,使用该图集的Sprite显示为粉色。
  • 排查
    1. 平台纹理设置:检查SpriteAtlasTextureSettings,确保为目标平台(如Android, iOS)设置了正确的压缩格式(ASTC, ETC2, PVRTC等)。如果设置错误或未覆盖,Unity可能会使用默认的未压缩格式,而目标平台不支持。
    2. 构建包含:确认SpriteAtlas资产本身被包含在了构建中。如果是Addressables,检查其所在的Addressables Group是否被正确构建和部署。
    3. 依赖关系:确保运行时加载SpriteAtlas的代码路径正确。如果通过Resources.Load或AssetBundle.Load加载,路径必须准确。如果是Addressables,确保使用了正确的Address或Label。
    4. 图集纹理格式:检查最终生成的图集纹理(通常是隐藏的.tex文件)的格式是否被目标平台支持。例如,在WebGL上使用ETC2压缩可能会出问题。
  • 解决:最稳妥的方式是完全遵循Unity的SpriteAtlas工作流。即:让工具只负责创建和配置SpriteAtlas资产(添加Sprite对象、设置参数),然后**调用SpriteAtlasUtility.PackAtlases**来让Unity生成最终的图集纹理。这样能最大程度保证兼容性。

5.3 与Addressables构建时出现冗余资源

  • 问题:构建后,发现散图纹理也被打包进了Addressables,导致包体变大。
  • 排查
    1. 在Addressables Groups窗口,检查这些散图纹理是否被直接分配到了某个组。如果是,将其移除。
    2. 检查散图纹理的Inspector,确保“Addressable”复选框没有被勾选。
    3. 使用Addressables的“Analyze”工具中的“Check Duplicate Bundle Dependencies”规则,分析是否有隐式的重复依赖。
  • 解决:编写一个构建前处理脚本(如实现IPreprocessBuildWithReport),在Addressables构建之前,遍历所有被SpriteAtlas引用的纹理,确保它们的Addressable状态为false,并且不在任何Addressables组中。同时,确保SpriteAtlas本身的Addressable状态为true。

5.4 性能瓶颈:大量纹理处理时编辑器卡顿

  • 问题:处理包含数千张小图的文件夹时,编辑器响应缓慢甚至无响应。
  • 优化
    1. 分帧处理:将纹理加载、规则匹配、打包计算等任务分解成小块,在EditorApplication.update回调或协程中逐帧处理,每帧处理N个,并更新进度条。
    2. 异步计算:将纯算法的部分(如MaxRects计算)放到Task.Run中在后台线程执行。但注意,任何涉及Unity API(如Texture2D,AssetDatabase)的操作都必须在主线程。
    3. 延迟加载纹理:预览时,不要一次性加载所有纹理的完整Texture2D对象。可以只加载它们的元数据(如尺寸、路径),或者使用AssetPreview.GetAssetPreview获取小缩略图,这个API是缓存的且相对高效。
    4. 缓存预览结果:将计算好的布局预览结果序列化缓存到磁盘,只要源文件和规则未变,下次打开窗口直接读取缓存,无需重新计算。

5.5 进阶功能设想

一个基础工具完成后,可以考虑以下方向增强:

  • 智能分组:除了基于路径/名称的规则,可以加入基于颜色深度、使用频率(热图)甚至依赖关系的自动分组建议。
  • 纹理预处理:集成简单的纹理预处理功能,如自动裁剪透明像素(Trim)、统一缩放至2的幂、格式转换(如将RGB24带Alpha通道的图自动转换为RGBA32)等。
  • 差分更新:对于大型项目,每次全量打包耗时。可以分析哪些图集内的图片发生了增删改,只重新打包受影响的图集。
  • 与CI/CD集成:提供命令行接口(CLI),使得该工具可以在持续集成服务器上运行,作为资源构建流水线的一环,确保每次构建使用的图集都是最新且一致的。

开发这样一个工具的过程,本身就是对Unity资源管线、编辑器扩展和算法应用的一次深度实践。它不仅能解决实际项目中的效率痛点,其设计思路和踩坑经验,对于理解引擎底层和构建更复杂的生产工具也大有裨益。最终,工具是否成功,取决于它是否真的让团队的美术和开发工作流变得更顺畅、更可靠。