公司动态
Unity动态图集实现:MaxRects算法与性能优化实战
1. 项目概述为什么我们需要动态图集在Unity项目开发中尤其是UI界面复杂、2D游戏元素繁多的场景里Draw Call绘制调用是性能优化的核心战场。每一个独立的材质和纹理都会大概率导致一次新的Draw Call。想象一下你的游戏主界面有几十个图标如果每个图标都使用单独的Sprite哪怕它们来自同一张图集只要材质实例不同GPU就需要为每一个图标进行一次“准备工作”和“绘制指令”这无疑是对性能的巨大浪费。静态图集Sprite Atlas是解决此问题的经典方案我们通常在编辑期使用Unity的Sprite Atlas工具或第三方工具如TexturePacker将大量小图打包成一张大图。但静态图集有其局限性它要求所有资源在打包前就必须确定。对于那些需要运行时动态加载、卸载的资源如玩家自定义头像、聊天表情、道具图标库或者资源总量巨大无法全部装入内存的情况静态图集就力不从心了。这时“动态图集”技术就登场了。它的核心思想是在游戏运行时根据当前需要显示的纹理动态地将它们合并到一张或几张大的纹理即图集中。当有新的纹理需要显示时算法会寻找图集中空闲的矩形区域将其“贴”上去当纹理不再需要时则释放其占用的区域留给后续使用。这就像一本可以随时擦写、重新排版的“活页相册”极大地提升了纹理管理的灵活性是优化运行时Draw Call的利器。2. 动态图集的核心算法与选型实现一个动态图集算法的核心是解决一个经典的“二维矩形装箱问题”2D Rectangle Packing Problem给定一个固定宽度或固定高度的大矩形图集和一系列大小不同的小矩形纹理如何将它们无重叠地放入大矩形中以最大化空间利用率。这并不是一个简单的问题它属于NP难问题。因此实践中我们采用各种启发式算法来寻找“足够好”的解决方案。下面我们来拆解几种在游戏开发中常用的算法。2.1 MaxRects 算法平衡效率与利用率的首选MaxRectsMaximum Rectangles算法是目前动态图集领域事实上的标准被广泛用于各种商业库中。它的思路非常直观且高效。算法核心思想初始状态整个图集就是一个最大的空闲矩形。当需要放入一个新纹理矩形时算法从当前维护的“空闲矩形列表”中根据某种“选择策略”Score挑选一个最适合的空闲矩形。将纹理放入选中的空闲矩形通常对齐其左上角或左下角。放入后这个被使用的空闲矩形会被移出列表。然后算法将这个被占用的矩形与列表中所有与之相交或相邻的空闲矩形进行切割生成新的、更小的空闲矩形加入列表同时移除被完全覆盖或分割后的旧矩形。关键的选择策略Score FunctionBest Short Side Fit (BSSF)选择放入后剩余空间短边最小的那个空闲矩形。这倾向于产生更“方正”的剩余空间有利于后续放置。Best Long Side Fit (BLSF)选择放入后剩余空间长边最小的那个空闲矩形。Best Area Fit (BAF)选择放入后剩余面积最小的那个空闲矩形。这是最直观的策略旨在最小化空间浪费。Bottom-Left (BL)选择满足条件且Y坐标最小若Y相同则X最小的空闲矩形。这更像是一种“顺序填充”实现简单但空间利用率可能不高。实操心得在Unity的动态图集实现中Best Short Side Fit (BSSF)通常是综合表现最好的策略。它在空间利用率和算法复杂度之间取得了很好的平衡。你可以尝试实现多种策略并在你的特定资源集上进行性能测试。算法优势空间利用率高尤其在纹理尺寸方差较大时表现优异。算法时间复杂度相对可控适合实时或准实时运行。算法挑战空闲矩形的切割与管理逻辑较为复杂容易产生大量细碎的小空闲矩形碎片化。需要定期进行“碎片整理”即重建图集Repack以合并碎片空间。2.2 Shelf Packing 算法简单高效的替代方案Shelf Packing货架打包算法将图集视为多个水平或垂直的“货架”。每个货架有固定的高度或宽度宽度或高度等于图集的宽度或高度。算法核心思想维护一个当前开放的货架列表。对新纹理优先尝试放入现有货架的剩余空间中通常从左到右顺序放置。如果所有现有货架都放不下则开启一个新的货架放在最下面或最右边。放置时通常会将纹理按高度或宽度排序优先放入高度或宽度最匹配的货架以减少垂直或水平空间的浪费。变种Guillotine Cut (断头台切割)这是Shelf Packing的一种改进。当在货架或一个空闲矩形中放入一个纹理后不是简单地从剩余宽度中减去而是进行矩形切割。通常有两种切割方式Shorter Leftover Axis选择切割后产生的两个新矩形中较短边更长的那个分割方式。这有助于产生更“可用”的剩余空间。Longer Leftover Axis选择切割后产生的两个新矩形中较长边更长的分割方式。Max Area选择切割后产生的两个新矩形面积之和最大的方式。算法优势实现极其简单运行速度快。空闲空间管理简单只有当前货架的剩余宽度和一系列矩形。算法劣势空间利用率通常低于MaxRects尤其是当纹理高度或宽度差异很大时会在货架末尾留下无法利用的“空隙”。更适合纹理高度或宽度相对统一的场景。2.3 算法选型与实践建议对于Unity中的动态图集我的建议是追求高利用率与通用性首选MaxRects (BSSF策略)。这是经过工业验证的方案能应对大多数复杂情况。虽然实现稍复杂但有许多开源实现可供参考。追求极简与高性能如果您的纹理尺寸相对规整例如都是图标尺寸的倍数如64x64, 128x128Shelf Packing (Guillotine Cut变种)是一个非常好的选择它实现快运行开销极小。混合策略一种高级策略是同时维护多个图集实例针对不同尺寸范围的纹理使用不同的算法。例如大纹理用MaxRects单独管理小纹理用Shelf Packing集中管理。注意事项无论选择哪种算法都必须实现“图集重建Repack”机制。当碎片化严重到无法放入新纹理时或者定期如每帧/每N次操作后需要将当前所有有效纹理重新打包到一个新的、干净的图集中。这是一个CPU开销较大的操作需要合理安排触发时机避免卡顿。3. 在Unity中实现动态图集架构与核心代码解析理论说完了我们动手实现一个基于MaxRects算法的简易动态图集管理器。我们将它设计成一个单例类DynamicAtlasManager。3.1 核心数据结构定义首先定义我们需要的基本结构。// 定义一个矩形结构用于表示纹理位置和空闲区域 public struct RectInt { public int x; public int y; public int width; public int height; // ... 构造函数、辅助方法如Contains, Overlaps等 } // 表示图集中的一项纹理 public class AtlasItem { public string Key; // 纹理的唯一标识如路径或ID public RectInt Rect; // 该纹理在图集中的位置和大小 public Texture2D Texture; // 原始的纹理引用可选用于重建时上传 } // 表示一个空闲矩形 public class FreeRect { public RectInt Rect; }3.2 动态图集管理器核心框架using UnityEngine; using System.Collections.Generic; public class DynamicAtlasManager : MonoBehaviour { private static DynamicAtlasManager _instance; public static DynamicAtlasManager Instance { get { return _instance; } } public int AtlasSize 2048; // 图集尺寸建议为2的幂次方 public TextureFormat AtlasFormat TextureFormat.RGBA32; // 图集格式 public FilterMode FilterMode FilterMode.Bilinear; // 过滤模式 public int Padding 2; // 纹理间的间隔防止纹理边缘渗色 private Texture2D _atlasTexture; // 动态生成的图集纹理 private ListFreeRect _freeRects; // 当前的空闲矩形列表 private Dictionarystring, AtlasItem _items; // 已存储的纹理项 private Material _atlasMaterial; // 使用此图集的材质球 void Awake() { if (_instance ! null _instance ! this) { Destroy(this.gameObject); } else { _instance this; DontDestroyOnLoad(this.gameObject); InitializeAtlas(); } } private void InitializeAtlas() { // 创建一张空白的RenderTexture或Texture2D作为图集 _atlasTexture new Texture2D(AtlasSize, AtlasSize, AtlasFormat, false); _atlasTexture.filterMode FilterMode; _atlasTexture.wrapMode TextureWrapMode.Clamp; // 通常使用Clamp防止边缘采样错误 _atlasTexture.name DynamicAtlas; // 初始时整个图集就是一个空闲矩形 _freeRects new ListFreeRect { new FreeRect { Rect new RectInt(0, 0, AtlasSize, AtlasSize) } }; _items new Dictionarystring, AtlasItem(); // 创建一个使用此图集的默认UI材质如果用于UI _atlasMaterial new Material(Shader.Find(UI/Default)); _atlasMaterial.mainTexture _atlasTexture; } }3.3 MaxRects算法核心实现接下来是实现算法的核心部分TryPack方法。public bool TryAddTexture(string key, Texture2D texture, out RectInt uvRect, out Material material) { uvRect new RectInt(); material null; // 1. 检查是否已存在 if (_items.TryGetValue(key, out AtlasItem existingItem)) { uvRect existingItem.Rect; material _atlasMaterial; return true; } // 2. 计算带Padding的纹理尺寸 int reqWidth texture.width Padding * 2; int reqHeight texture.height Padding * 2; // 3. 尝试使用MaxRects算法寻找放置位置 FreeRect selectedFreeRect null; int bestScore int.MaxValue; // 使用BSSF策略分数是短边剩余 foreach (var freeRect in _freeRects) { if (freeRect.Rect.width reqWidth freeRect.Rect.height reqHeight) { // 计算放置后的剩余空间 int leftoverWidth freeRect.Rect.width - reqWidth; int leftoverHeight freeRect.Rect.height - reqHeight; // Best Short Side Fit 策略选择剩余短边最小的 int shortSide Mathf.Min(leftoverWidth, leftoverHeight); if (shortSide bestScore) { bestScore shortSide; selectedFreeRect freeRect; } } } if (selectedFreeRect null) { // 空间不足触发重建或扩容 Debug.LogWarning($[DynamicAtlas] Failed to pack texture {key}. Triggering repack...); if (!RepackAtlas()) { // 重建后依然放不下说明纹理可能过大或图集已满 Debug.LogError($[DynamicAtlas] Texture {key} is too large or atlas is full.); return false; } // 重建后重试 return TryAddTexture(key, texture, out uvRect, out material); } // 4. 找到位置放置纹理左上角对齐 uvRect new RectInt(selectedFreeRect.Rect.x Padding, selectedFreeRect.Rect.y Padding, texture.width, texture.height); // 5. 将纹理数据写入图集这是一个CPU密集型操作 // 注意这里需要处理纹理的Read/Write权限。建议在加载纹理时就确保它是可读的。 Graphics.CopyTexture(texture, 0, 0, 0, 0, texture.width, texture.height, _atlasTexture, 0, 0, uvRect.x, uvRect.y); // 6. 更新空闲矩形列表MaxRects的核心 UpdateFreeRects(selectedFreeRect, new RectInt(selectedFreeRect.Rect.x, selectedFreeRect.Rect.y, reqWidth, reqHeight)); // 7. 记录该项 AtlasItem newItem new AtlasItem { Key key, Rect uvRect, Texture texture }; _items.Add(key, newItem); // 8. 返回UV坐标和材质 // UV坐标需要从像素坐标转换为0-1范围 // 通常我们在Shader或Image组件中处理这里返回像素矩形由使用者转换。 material _atlasMaterial; return true; } private void UpdateFreeRects(FreeRect usedRect, RectInt placedRect) { // 从空闲列表中移除被使用的矩形 _freeRects.Remove(usedRect); // 切割并生成新的空闲矩形 ListFreeRect newRects new ListFreeRect(); foreach (var freeRect in _freeRects) { // 如果当前空闲矩形与放置的矩形相交则进行切割 if (freeRect.Rect.Overlaps(placedRect)) { // 矩形切割逻辑简化版实际需要更严谨的几何判断 // 原则用放置的矩形去“切”空闲矩形可能产生至多4个新的小矩形 // 这里提供一个概念性实现实际项目需完善 GenerateSplitRects(freeRect.Rect, placedRect, newRects); } else { // 不相交保留原矩形 newRects.Add(freeRect); } } // 可选合并相邻的空闲矩形以减少碎片复杂操作可定期进行 // MergeFreeRects(newRects); _freeRects newRects; } // 一个简化的矩形切割示例仅作示意非完整实现 private void GenerateSplitRects(RectInt free, RectInt placed, ListFreeRect result) { // 计算放置矩形在空闲矩形内部产生的可能分割 // 1. 上方的矩形 if (placed.y free.y) { result.Add(new FreeRect { Rect new RectInt(free.x, free.y, free.width, placed.y - free.y) }); } // 2. 下方的矩形 if (placed.y placed.height free.y free.height) { result.Add(new FreeRect { Rect new RectInt(free.x, placed.y placed.height, free.width, (free.y free.height) - (placed.y placed.height)) }); } // 3. 左侧的矩形 if (placed.x free.x) { result.Add(new FreeRect { Rect new RectInt(free.x, Mathf.Max(free.y, placed.y), placed.x - free.x, placed.height) }); } // 4. 右侧的矩形 if (placed.x placed.width free.x free.width) { result.Add(new FreeRect { Rect new RectInt(placed.x placed.width, Mathf.Max(free.y, placed.y), (free.x free.width) - (placed.x placed.width), placed.height) }); } }3.4 图集重建Repack机制当碎片化严重或无法放入新纹理时必须重建图集。private bool RepackAtlas() { // 1. 收集所有当前需要保留的纹理和它们的原始数据 ListAtlasItem itemsToRepack new ListAtlasItem(_items.Values); // 可以在这里根据LRU最近最少使用策略丢弃一些不常用的纹理以减轻压力 // 2. 清空当前图集和数据结构 _freeRects.Clear(); _freeRects.Add(new FreeRect { Rect new RectInt(0, 0, AtlasSize, AtlasSize) }); // 创建一个新的临时纹理来重建避免直接清除当前正在被渲染的图集 Texture2D newAtlasTexture new Texture2D(AtlasSize, AtlasSize, AtlasFormat, false); newAtlasTexture.filterMode FilterMode; newAtlasTexture.wrapMode TextureWrapMode.Clamp; _items.Clear(); // 3. 按照某种顺序如面积从大到小对纹理进行排序这通常能提高打包效率 itemsToRepack.Sort((a, b) (b.Texture.width * b.Texture.height).CompareTo(a.Texture.width * a.Texture.height)); // 4. 按顺序重新尝试打包所有纹理 foreach (var item in itemsToRepack) { RectInt newRect; Material dummyMat; if (!TryPackToAtlasInternal(item.Key, item.Texture, newAtlasTexture, _freeRects, out newRect)) { // 如果连重建都放不下说明图集尺寸可能不够或者需要更激进的纹理丢弃策略 Debug.LogError($[DynamicAtlas] Repack failed for texture {item.Key}. Atlas might be too small.); // 恢复旧状态或者部分丢弃这里需要根据业务逻辑处理。 return false; } // 更新Item的Rect item.Rect newRect; _items[item.Key] item; } // 5. 用新的图集纹理替换旧的 // 注意这里涉及到纹理的更新如果图集正在被GPU使用需要小心处理。 // 一种安全做法是在渲染线程空闲时如帧末进行替换。 Destroy(_atlasTexture); _atlasTexture newAtlasTexture; _atlasTexture.name DynamicAtlas_Repacked; _atlasMaterial.mainTexture _atlasTexture; Debug.Log($[DynamicAtlas] Repack completed. {_items.Count} textures packed.); return true; } // 一个内部打包方法用于重建过程 private bool TryPackToAtlasInternal(string key, Texture2D tex, Texture2D targetAtlas, ListFreeRect freeRectList, out RectInt rect) { // ... 实现逻辑与 TryAddTexture 中的打包部分类似但操作的是传入的targetAtlas和freeRectList rect new RectInt(); // ... (省略具体实现) return true; }4. 性能优化、常见问题与实战技巧动态图集是一个“空间换时间”和“CPU换GPU”的典型方案。实现它不难但要用好、用稳需要大量的细节打磨。4.1 性能瓶颈分析与优化CPU开销 - 纹理上传 (Graphics.CopyTexture)问题每次添加纹理都需要将CPU内存中的数据拷贝到GPU纹理这是同步操作如果一帧内添加大量纹理会造成卡顿。优化批处理不要每加载一个纹理就立即加入图集。可以累积一批纹理请求在帧末如LateUpdate或加载场景的间隙进行批量添加和上传。异步上传对于支持AsyncGPUReadback或Graphics.CopyTexture在某些平台有异步版本的可以考虑使用但复杂度较高。预分配与池化对于已知的、高频使用的小纹理如数字、状态图标可以在游戏初始化时预加载到动态图集中。CPU开销 - 算法计算 (TryPack,UpdateFreeRects)问题MaxRects算法需要遍历和切割矩形纹理数量多时计算量不小。优化限制图集数量不要无限制创建动态图集。根据纹理格式和大小创建多个固定大小的动态图集如RGBA32_2048,RGB24_1024将纹理按格式和大小范围分类放入。简化算法对于性能极其敏感的场景可以降级使用更简单的Shelf算法。空间换时间缓存一些常见尺寸纹理的“最佳放置位置”预测。GPU与内存开销问题动态图集纹理通常较大2048x2048即使空闲区域很多整张纹理也需要占用GPU内存和带宽。优化选择合适的尺寸不是越大越好。2048x2048是PC和主机的常用起点对于移动端1024x1024或512x512可能更合适可以创建多个小图集。纹理格式根据需求选择RGBA32,RGB24,RGBA16(半精度)甚至压缩格式如ASTC或ETC2。但注意动态写入压缩纹理通常不被支持你可能需要先写入RGBA32再在适当时机压缩或使用可写的压缩格式如果平台支持。Mipmaps动态图集通常不生成Mipmaps因为动态更新会使其失效且UI/2D元素通常不需要。4.2 常见问题与排查技巧实录问题1纹理边缘出现“渗色”或相邻纹理像素干扰。原因纹理采样时由于双线性/三线性过滤会采样到相邻纹理的像素。Padding就是为了解决这个问题。解决确保Padding足够通常2-4像素即可。在将纹理拷贝到图集时拷贝区域要包含Padding。Padding内容Padding区域不能留空或为随机值。最佳实践是扩展边缘像素Clamp模式或者填充纹理的透明黑色0,0,0,0。可以在拷贝纹理后手动填充Padding区域的像素。Shader采样调整在采样图集的Shader中根据纹理在图集中的实际Rect对UV坐标进行微调向内收缩半个或一个像素确保采样点落在纹理安全区域内。问题2动态图集上的Sprite显示为紫色。原因这是Unity中Shader丢失或材质球问题的典型表现。但具体到动态图集可能原因有动态创建的_atlasMaterial使用的Shader不存在或未包含在构建中。图集纹理_atlasTexture没有被成功创建或上传其width或height为0。分配给UI Image等组件的材质球实例丢失。排查在运行时检查_atlasTexture是否不为null且width/height正确。检查_atlasMaterial的shader属性是否有效。使用Frame Debugger工具查看绘制调用时使用的纹理和材质是否正确绑定。问题3图集空间很快被耗尽频繁触发重建导致性能下降。原因纹理只加不减或者碎片化严重。解决实现纹理引用计数与释放为每个AtlasItem添加引用计数。当某个纹理不再被任何UI元素或精灵引用时将其标记为“可释放”。在图集重建时直接丢弃这些纹理。更智能的重建策略不要等到完全放不下才重建。可以设置阈值例如当空闲空间碎片化到最大连续矩形小于图集面积的20%时触发一次轻量级重建。多图集分流如前所述使用多个动态图集分担压力。问题4在WebGL或某些移动平台上动态纹理更新非常慢或无效。原因不同平台对动态更新纹理SetPixels,Graphics.CopyTexture,Apply的支持和性能差异很大。WebGL由于JavaScript与WebGL上下文通信开销尤其慢。解决减少更新频率这是最重要的原则。批量操作避免每帧更新。使用Graphics.CopyTexture替代SetPixelsApplyGraphics.CopyTexture通常在GPU间或CPU到GPU间直接传输数据比SetPixelsCPU修改Apply上传到GPU高效得多。检查纹理读写权限确保源纹理texture在导入设置或创建时启用了Read/Write Enabled。平台特定处理对于WebGL考虑将动态图集功能降级或使用AssetBundle加载预先打包好的静态图集变体。4.3 进阶技巧与扩展思路与Unity UI (uGUI) 集成创建一个DynamicAtlasImage组件继承自Image。它内部持有纹理的Key在OnEnable时向DynamicAtlasManager请求该纹理获取其UV矩形并动态设置material和uvRect。在OnDisable时通知管理器减少引用计数。这需要对uGUI的网格重建有一定了解确保材质和UV的正确设置。与SpriteRenderer集成原理类似你需要动态创建一个使用图集材质的MaterialPropertyBlock并设置其纹理偏移和缩放(_MainTex_ST)然后通过SpriteRenderer.SetPropertyBlock应用。支持纹理旋转为了进一步提高空间利用率一些高级打包算法支持将纹理旋转90度放入。这需要在算法中考虑旋转后的尺寸并在Shader中通过UV变换来处理旋转。MaxRects算法可以扩展以支持此功能。监控与调试工具在编辑器中创建一个调试窗口实时可视化动态图集的使用情况用不同颜色绘制已占用区域和空闲区域显示空间利用率、纹理数量、碎片化程度等指标。这对于调优图集尺寸和算法参数至关重要。实现一个健壮、高效的动态图集系统是深入理解Unity渲染和资源管理的好机会。它没有银弹需要你根据项目具体需求平台、艺术风格、性能预算仔细权衡和调优。从一个小而美的原型开始逐步添加引用计数、批量处理、多图集管理和调试工具你会构建出一个强大且专属的优化利器。