Unity动态图集方案

前言:什么时候需要动态图集?
先想两个场景:
场景 A:背包 ------ 玩家背包里可能有 200 件道具,每件道具的图标都不一样。你不知道玩家会开出什么装备、会合成什么材料。不可能把所有道具图标都塞进一张静态图集里------塞不下,也浪费内存。
场景 B:主场景 UI ------ 大地图上显示玩家头像、队伍成员头像、任务 NPC 头像、附近玩家头像。这些头像可能是玩家自定义的、工会徽章、不同怪物的贴图。同样,你没法预先把所有可能出现的头像绑到一张图集里。
这两个场景的共同特征是:用哪些图是运行时才能确定的,候选资源是海量的。
静态图集解决不了这个问题,所以需要 动态图集 ------ 在运行时动态地把散图拼成一张大纹理,按需加载、按需淘汰。
一、核心算法:矩形装箱
所有图集方案底层都绕不开一个几何问题:怎么把一堆大小不一的矩形,尽可能紧凑地塞进一张大矩形里?
1.1 MaxRects(最大矩形法)
这是 Unity SpriteAtlas 打包工具底层用的核心算法。
思路非常简单:
- 一开始你有一张空白大图(比如 2048×2048),这就是一个"空白矩形"
- 放第一张小图进去,剩余空间被切成几个"空白矩形碎片"
- 放下一张图时,在所有空白矩形里找一个最能塞下它的,塞进去
- 塞完后剩下的碎片接着留着用
类比切蛋糕:你有一整块蛋糕(图集),要切出各种形状的块(小图)。每切一块,剩下的蛋糕碎块你接着装盘。MaxRects 的追求是:哪块碎蛋糕最不浪费,我就在哪切。
优点 :空间利用率极高,通常能达到 90%-95%
缺点:每次都遍历所有"空白矩形",CPU 开销大
为什么 Unity 打包工具敢用 MaxRects? 因为它是离线操作,只在打 AssetBundle 的时候跑一次,不在乎那几毫秒的 CPU 时间。
1.2 Shelf / Skyline(货架法)
这是运行时动态图集最常用的算法,也是 Unity 早期版本内部推荐的做法。
思路换了个角度:
- 从大图底部开始画一条"天际线"
- 每来一张小图,从左往右找天际线上最矮的位置塞进去
- 塞完后,天际线被更新
类比整理衣柜:你把 T 恤从下往上叠,哪一堆叠得最矮就往哪上面放。放完这堆变高了,再找下一堆最矮的。不叠超过柜子高度就行。
优点:
- 运行速度快,不需要遍历所有空白块
- 实现简单,容易控制
缺点:
- 空间利用率比 MaxRects 低 5-10%,容易产生"锯齿状"浪费
运行时动态图集用 Shelf/Skyline 的理由:背包里的图标是运行时才确定的,每帧都可能要往里塞新图,不能再像打包时那样做精确计算------速度比极致省空间更重要。
1.3 Shelf 算法的代码实现
下面是一个完整可用的 Shelf bin packer,核心逻辑就三个操作:找货架 → 放上去 → 更新天际线。
csharp
using UnityEngine;
using System.Collections.Generic;
/// <summary>
/// Shelf / Skyline 矩形装箱算法
/// 核心思路:从底部开始维护一条"天际线",每张图往最矮的货架上放
/// </summary>
public class ShelfPacker
{
int mAtlasWidth;
int mAtlasHeight;
int mPadding; // 图与图之间的间隔(防止边缘采样溢出)
// 天际线节点:每个节点记录"从x位置开始,当前最高点到多少"
List<SkylineNode> mSkyline;
struct SkylineNode
{
public int x; // 起始 x 坐标(从左往右)
public int y; // 当前高度(从底部往上)
public int width; // 这个高度段持续多宽
public SkylineNode(int x, int y, int width)
{
this.x = x;
this.y = y;
this.width = width;
}
}
public ShelfPacker(int width, int height, int padding = 2)
{
mAtlasWidth = width;
mAtlasHeight = height;
mPadding = padding;
// 初始天际线:底部一条水平线
mSkyline = new List<SkylineNode> { new SkylineNode(0, 0, width) };
}
/// <summary>
/// 尝试把一张宽w、高h的图塞进图集
/// </summary>
/// <param name="w">图片宽度</param>
/// <param name="h">图片高度</param>
/// <param name="outX">塞入后的x坐标</param>
/// <param name="outY">塞入后的y坐标</param>
/// <returns>是否塞入成功</returns>
public bool Pack(int w, int h, out int outX, out int outY)
{
outX = 0;
outY = 0;
// 加上 padding,让图之间留缝隙
int pw = w + mPadding;
int ph = h + mPadding;
// 单张图超过图集大小 → 不可能塞进去
if (pw > mAtlasWidth || ph > mAtlasHeight)
return false;
int bestX = int.MaxValue;
int bestY = int.MaxValue;
int bestIndex = -1;
// 遍历天际线,找到"最矮且最靠左"的位置
for (int i = 0; i < mSkyline.Count; i++)
{
int currentX = mSkyline[i].x;
int currentY = mSkyline[i].y;
// 如果当前位置已经高于可接受高度,跳过
if (currentY + ph > mAtlasHeight)
continue;
// 检查从当前位置开始,宽度够不够放这张图
int availableWidth = CheckWidth(i, pw);
if (availableWidth < pw)
continue;
// 找到更矮的位置,或者同样矮但更靠左的位置
if (currentY < bestY || (currentY == bestY && currentX < bestX))
{
bestX = currentX;
bestY = currentY;
bestIndex = i;
}
}
// 没有找到合适位置 → 图集满了
if (bestIndex == -1)
return false;
outX = bestX;
outY = bestY;
// 把这张图"放上去",更新天际线
InsertNode(bestIndex, bestX, bestY + ph, pw);
return true;
}
/// <summary>
/// 从位置index开始,检查连续多宽的天际线高度 <= currentY
/// 其实就是看"这层货架够不够宽放这张图"
/// </summary>
int CheckWidth(int startIndex, int needWidth)
{
int startY = mSkyline[startIndex].y;
int width = 0;
for (int i = startIndex; i < mSkyline.Count; i++)
{
// 如果遇到更高的节点,说明货架"断"了,后面的位置更浅
if (mSkyline[i].y > startY)
break;
width += mSkyline[i].width;
if (width >= needWidth)
return width;
}
return width;
}
/// <summary>
/// 在天际线中插入一个"凸起"(因为放了一张图,高度增加了)
/// 同时合并相邻的节点,不让天际线出现"锯齿"
/// </summary>
void InsertNode(int index, int x, int y, int width)
{
// 把覆盖到的旧节点删掉
int removeCount = 0;
int coveredWidth = 0;
for (int i = index; i < mSkyline.Count; i++)
{
coveredWidth += mSkyline[i].width;
removeCount++;
if (coveredWidth >= width)
break;
}
// 删掉被覆盖的旧节点,插入新节点
mSkyline.RemoveRange(index, removeCount);
mSkyline.Insert(index, new SkylineNode(x, y, width));
// 如果新节点后面还有节点,需要把剩余宽度补回去
int remaining = coveredWidth - width;
if (remaining > 0 && index + 1 < mSkyline.Count)
{
int nextX = mSkyline[index + 1].x;
mSkyline.Insert(index + 1, new SkylineNode(nextX, mSkyline[index + 1].y, remaining));
}
// 合并相邻的等高节点,避免天际线节点太多
MergeNodes();
}
/// <summary>
/// 如果相邻两个节点高度相同,合并成一个宽的节点
/// 减少节点数量,加快后续的 CheckWidth 遍历
/// </summary>
void MergeNodes()
{
for (int i = 0; i < mSkyline.Count - 1; i++)
{
if (mSkyline[i].y == mSkyline[i + 1].y)
{
mSkyline[i] = new SkylineNode(
mSkyline[i].x,
mSkyline[i].y,
mSkyline[i].width + mSkyline[i + 1].width
);
mSkyline.RemoveAt(i + 1);
i--;
}
}
}
/// <summary>
/// 清空图集,重置天际线
/// </summary>
public void Clear()
{
mSkyline.Clear();
mSkyline.Add(new SkylineNode(0, 0, mAtlasWidth));
}
/// <summary>
/// 当前图集利用率(已占面积 / 总面积)
/// </summary>
public float Utilization
{
get
{
// 天际线的积分面积就是已占用面积
// 实际项目中可以直接用已塞入图片总面积 / (宽×高)
return 0f; // 占位,具体实现需要维护已用面积计数器
}
}
}
关键方法一句话解释:
| 方法 | 作用 |
|---|---|
CheckWidth() |
从左往右看,这层"货架"够不够宽,中途遇到更高的"台阶"就算断 |
InsertNode() |
放完图后,把天际线"顶起来",删掉被覆盖的旧节点,插一个新凸起 |
MergeNodes() |
相邻的两个货架一样高 → 合并成一个,减少节点数量,加速后续查找 |
这个 packer 本身只算坐标、不操作纹理。真正塞图的时候配合 Graphics.CopyTexture 把像素拷进去就行。
二、图集缓存淘汰算法
塞满了怎么办?得踢出去一些旧的,给新的腾地方。踢谁出去,就是这个算法的核心。
2.1 LRU(Least Recently Used,最久未使用)
核心思想:把最长时间没被用到的图踢掉。
生活类比:你手机后台有 10 个 App。支付宝刚刚还在用,微信半小时前打开的,小红书昨天打开的。现在要打开相机------内存不够了。LRU 会优先杀小红书,因为它在后台"发呆"最久。
实现方式:给每张小图记一个"最近使用时间戳"。图集满了就找时间戳最旧的,踢掉腾位置。
代码层面就是一张双向链表 + 哈希表(LinkedHashMap):
- 每次用到某张小图,把它移到链表头部
- 需要淘汰时,直接从链表尾部摘
适合场景:背包、头像、技能图标------这些资源有明显的"访问局部性":当前正在看的这页背包里的图标被反复渲染,翻页后才换一批。
2.2 LFU(Least Frequently Used,最不常用)
核心思想:把用得最少的图踢掉。
生活类比:你手机的 App 使用统计。微信每天打开 50 次,相册 30 次,那个"计算器"一个月点一次。要清后台时,杀计算器。
优点 :不会被"刚用一次就长期占坑"的资源污染
缺点:可能有些图曾经很常用但再也不需要了(比如活动图标),但使用次数依然很高,占着坑不走
2.3 实际项目怎么选
| 算法 | 适合场景 | 理由 |
|---|---|---|
| LRU | 背包、聊天表情、道具图标 | 用户翻页、打开关闭背包有清晰的时间序列 |
| LFU | 战斗 UI、Buff 图标、技能图标 | 高频技能一直显示,偶尔弹出的活动图标用完就滚 |
| LRU + 引用计数 | 主场景头像、队友信息 | 某些图标"必须展示"(不死),LRU 只淘汰"可隐藏"的 |
实际项目中通常是 LRU + 人工标记"常驻不死" 的混合策略。比如主角头像永远不淘汰,队友头像走 LRU。
2.4 LRU 淘汰算法的代码实现
一个专门给图集用的 LRU 缓存,用 LinkedList + Dictionary 实现 O(1) 的查找和淘汰:
csharp
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// LRU 缓存,专门给动态图集用
/// 最近用过的图挂在链表头,要淘汰时直接从链表尾摘
/// 查找和淘汰都是 O(1)
/// </summary>
public class AtlasLRUCache
{
// 双向链表节点:每张小图就是一个节点
class LRUNode
{
public string textureKey; // 图片唯一标识(比如 "icon_sword_01")
public Rect uvRect; // 在图集中的 UV 坐标
public bool isPinned; // true = 常驻不死(比如主角头像)
public LRUNode(string key, Rect uv, bool pinned = false)
{
textureKey = key;
uvRect = uv;
isPinned = pinned;
}
}
int mMaxCount; // 最多缓存多少张图
Dictionary<string, LinkedListNode<LRUNode>> mDict; // key → 链表节点
LinkedList<LRUNode> mList; // 链表:头部=最近使用,尾部=最久未用
public AtlasLRUCache(int maxCount)
{
mMaxCount = maxCount;
mDict = new Dictionary<string, LinkedListNode<LRUNode>>();
mList = new LinkedList<LRUNode>();
}
/// <summary>
/// 用到某张图时调用,把它移到链表头部(表示"刚用过")
/// </summary>
public void Touch(string textureKey)
{
if (mDict.TryGetValue(textureKey, out var node))
{
mList.Remove(node);
mList.AddFirst(node);
}
}
/// <summary>
/// 获取某张图的 UV,同时标记为"最近使用"
/// </summary>
public Rect GetUV(string textureKey)
{
if (mDict.TryGetValue(textureKey, out var node))
{
// 移到链表头部
mList.Remove(node);
mList.AddFirst(node);
return node.Value.uvRect;
}
return Rect.zero;
}
/// <summary>
/// 往缓存里加一张图
/// </summary>
/// <returns>如果缓存满了被淘汰的 key,null 表示没淘汰</returns>
public string Add(string textureKey, Rect uv, bool pinned = false)
{
string evictedKey = null;
// 如果已存在,更新 UV 移到头部
if (mDict.TryGetValue(textureKey, out var existingNode))
{
existingNode.Value.uvRect = uv;
mList.Remove(existingNode);
mList.AddFirst(existingNode);
return null;
}
// 缓存满了 → 从尾部开始淘汰(跳过 pinned 的常驻节点)
while (mDict.Count >= mMaxCount)
{
var tail = mList.Last;
if (tail == null) break;
// pinned 节点不能被淘汰,把它移到头部再继续找
if (tail.Value.isPinned)
{
mList.RemoveLast();
mList.AddFirst(tail);
tail = mList.Last;
if (tail == null || tail.Value.isPinned)
break; // 全是常驻节点,淘汰不了
}
mDict.Remove(tail.Value.textureKey);
evictedKey = tail.Value.textureKey;
mList.RemoveLast();
break; // 一次只淘汰一个
}
// 插入到头部
var newNode = new LRUNode(textureKey, uv, pinned);
mList.AddFirst(newNode);
mDict[textureKey] = mList.First;
return evictedKey;
}
/// <summary>
/// 把某张图标为"常驻不死",LRU 永不淘汰它
/// </summary>
public void Pin(string textureKey)
{
if (mDict.TryGetValue(textureKey, out var node))
{
node.Value.isPinned = true;
}
}
/// <summary>
/// 取消常驻标记,之后可以被 LRU 淘汰
/// </summary>
public void Unpin(string textureKey)
{
if (mDict.TryGetValue(textureKey, out var node))
{
node.Value.isPinned = false;
}
}
/// <summary>
/// 清空所有缓存
/// </summary>
public void Clear()
{
mDict.Clear();
mList.Clear();
}
public int Count => mDict.Count;
public bool Contains(string key) => mDict.ContainsKey(key);
}
pinned 机制 :主角头像、技能栏这类"永远要显示"的图,标记 isPinned = true,LRU 淘汰时会跳过它们。这样就不用担心主角头像被踢出去导致下一秒又要重新加载。
三、从算法到工程:背包场景下的动态图集实现
3.1 背包为什么适合动态图集
- 数据量:一个游戏可能有几千件道具,每件都有自己的图标
- 不确定性:玩家背包里有什么是上帝才知道的
- 局部性:玩家一次只看一页(比如 20 件),翻页才会暴露下一批
- 峰值可控:同时渲染的图标有限(屏幕里最多显示一页 + 几个浮窗预览)
3.2 完整流程
玩家打开背包
↓
背包数据服务返回 [{ id: 1001, icon: "sword_01" }, ...]
↓
遍历当前页的道具,收集所有需要渲染的图标名称
↓
对每个图标:
├─ 已在图集中 → 记录 UV 坐标,直接渲染
└─ 不在图集中 → 加载贴图 → 用 Shelf 算法塞入图集 → 记录 UV
↓ 图集满了怎么办?
LRU 淘汰最久未用的旧图,腾出空间
↓
所有图标就绪 → 一次 Draw Call 渲染整页背包
3.3 DynamicAtlas 完整代码
把 Shelf 打包器 + LRU 缓存 + 实际纹理拷贝整合到一起,就是一个完整的动态图集:
csharp
using UnityEngine;
using System.Collections.Generic;
/// <summary>
/// 动态图集:运行时把散图拼成一张大纹理
///
/// 工作流程:
/// 1. 请求加载一张图 → 检查 LRU 缓存有没有
/// 2. 缓存命中 → 直接返回 UV
/// 3. 缓存没命中 → 加载原始 Texture → ShelfPacker 算位置 → CopyTexture 拷入图集
/// 4. 图集满了 → LRU 踢掉最久未用的图,腾出空间塞新的
/// </summary>
public class DynamicAtlas
{
RenderTexture mAtlasRT; // 图集的渲染纹理(GPU 上操作)
Texture2D mAtlasTex2D; // 如果不用 RenderTexture,用这个
ShelfPacker mPacker; // Shelf 装箱算法
AtlasLRUCache mLRU; // LRU 缓存
int mWidth;
int mHeight;
bool mIsDirty = false; // 是否需要刷新到 GPU
public DynamicAtlas(int width, int height, int maxCacheCount)
{
mWidth = width;
mHeight = height;
mPacker = new ShelfPacker(width, height, padding: 2);
mLRU = new AtlasLRUCache(maxCacheCount);
// 创建一张 RenderTexture 作为图集载体
// 用 RenderTexture 的好处是 CopyTexture 走 GPU,不阻塞主线程
mAtlasRT = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32);
mAtlasRT.Create();
// 清空为黑色
Graphics.SetRenderTarget(mAtlasRT);
GL.Clear(false, true, Color.clear);
Graphics.SetRenderTarget(null);
}
/// <summary>
/// 请求一张图,返回它在图集中的 UV 坐标
/// 如果缓存命中直接返回,没命中才去加载和打包
/// </summary>
public bool Request(string textureKey, Texture2D sourceTex, out Rect uvRect)
{
uvRect = Rect.zero;
// 第1步:检查 LRU 缓存
if (mLRU.Contains(textureKey))
{
uvRect = mLRU.GetUV(textureKey);
return true;
}
// 第2步:缓存没命中 → 用 Shelf 算法算位置
int packedX, packedY;
if (!mPacker.Pack(sourceTex.width, sourceTex.height, out packedX, out packedY))
{
// 第3步:图集满了 → LRU 淘汰一张
string evictedKey = mLRU.Add(textureKey, Rect.zero); // 占个位触发淘汰
if (evictedKey != null)
{
// 淘汰成功后,清空被淘汰图占的区域(实际项目中用空洞管理更精细)
mPacker.Clear();
RebuildAll(); // 重建所有还在缓存里的图
}
// 再试一次
if (!mPacker.Pack(sourceTex.width, sourceTex.height, out packedX, out packedY))
{
Debug.LogError($"图集已满,且无法淘汰:{textureKey}");
return false;
}
}
// 第4步:把原图拷贝到图集中计算出的位置
uvRect = new Rect(
(float)packedX / mWidth,
(float)packedY / mHeight,
(float)sourceTex.width / mWidth,
(float)sourceTex.height / mHeight
);
// GPU 拷贝:从源纹理到图集指定区域
Graphics.CopyTexture(
sourceTex, 0, 0, 0, 0, sourceTex.width, sourceTex.height,
mAtlasRT, 0, 0, packedX, packedY
);
// 第5步:记录到 LRU 缓存
mLRU.Add(textureKey, uvRect);
mIsDirty = true;
return true;
}
/// <summary>
/// 用完后标记一下,不用每次都重建
/// 适合背包翻页场景:如果只是引用计数-1不等同于要立刻淘汰
/// </summary>
public void Release(string textureKey)
{
// 通常啥也不做,等 LRU 自然淘汰
// 但如果明确知道"这张图再也不用了",可以提前 Unpin
mLRU.Unpin(textureKey);
}
/// <summary>
/// 把某张图标为常驻,LRU 永不淘汰
/// </summary>
public void Pin(string textureKey)
{
mLRU.Pin(textureKey);
}
/// <summary>
/// 图集满了需要淘汰时,重建所有还在缓存里的图
/// </summary>
void RebuildAll()
{
// 遍历 LRU 里的所有 key,重新 Pack + CopyTexture
// 这是个重型操作,不要每帧调用
// 实际项目中通常不做全量重建,而是用"空洞复用"策略:
// 被淘汰的图位置记为"空洞",新图优先塞进大小合适的空洞
}
/// <summary>
/// 获取最终图集纹理,交给 Material 使用
/// </summary>
public RenderTexture GetAtlasTexture()
{
return mAtlasRT;
}
/// <summary>
/// 销毁图集,释放 GPU 内存
/// </summary>
public void Destroy()
{
if (mAtlasRT != null)
{
mAtlasRT.Release();
Object.DestroyImmediate(mAtlasRT);
mAtlasRT = null;
}
mLRU.Clear();
mPacker.Clear();
}
}
使用示例 ------ 背包翻页:
csharp
public class BackpackPanel : MonoBehaviour
{
DynamicAtlas mAtlas;
Dictionary<int, string> mItemIconMap; // 道具ID → 图标名称
void Start()
{
// 2048×2048 的图集,最多缓存 200 张小图
mAtlas = new DynamicAtlas(2048, 2048, 200);
}
// 打开背包第 N 页
void ShowPage(List<int> itemIds)
{
foreach (int itemId in itemIds)
{
string iconName = mItemIconMap[itemId];
Texture2D iconTex = Resources.Load<Texture2D>($"Icons/{iconName}");
// 请求图集打包
if (mAtlas.Request(iconName, iconTex, out Rect uv))
{
// uv 就是这张图在图集里的坐标
// 把这个 uv 设给 RawImage 或 修改 Mesh 的 UV
SetupItemIcon(itemId, mAtlas.GetAtlasTexture(), uv);
}
}
// 整页 20 个道具的图标全部在同一张图集上
// → 只需要 1 个 Draw Call
}
}
3.4 关键设计点
(1)图集尺寸:建议用 2048×2048 或 4096×4096。太大了内存扛不住,太小了频繁淘汰。
(2)图标归一化 :背包道具图标通常是 100×100 或 64×64 的方形。尺寸越统一,Shelf 算法的空间浪费越少。所以设计时就定好图标规范:背包图标全是 100×100,装备图标全是 120×120。
(3)翻页策略 :翻页时不是全部清空重建。新页的图标如果已经在图集里(之前看过这页),直接复用;不在的再加载。这就是 LRU 的意义------翻回前一页,缓存大概率还在。
(4)预加载:背包打开时,不要等到渲染才加载图。拿到背包数据后,异步把当前页所有图标加载 + 塞入图集。这个过程几十毫秒,用户看到的是"背包瞬间打开"。
四、从算法到工程:主场景 UI 下的动态图集实现
4.1 主场景 UI 为什么更难
主场景 UI 的问题是资源种类繁杂:
- 玩家头像(可能来自玩家自定义上传)
- 队伍成员头像(动态变化)
- 任务追踪的 NPC 头像
- 小地图图标
- 血条上的 buff/debuff 图标
- 交互按钮图标(拾取、对话、攻击)
- 伤害数字字体(整张数字图)
- 头顶名字的背景图
这些资源来自不同的模块、不同的加载时机,如果各管各的,一张图集根本不能合批。
4.2 分层图集策略
解决思路:不要只有一张图集,按层级分多张。
csharp
// 伪代码示意
public class DynamicAtlasManager
{
// 常驻图集:永不淘汰
DynamicAtlas permanentAtlas; // 血条底图、按钮背景、通用ICON
// 场景图集:随场景切换整体替换
DynamicAtlas sceneAtlas; // NPC头像、场景交互图标
// 动态图集:走 LRU 淘汰
DynamicAtlas dynamicAtlas; // 队友头像、玩家头像、临时 buff 图标
}
三层分工:
| 层级 | 生命周期 | 淘汰策略 | 用途 |
|---|---|---|---|
| 常驻层 | 永久 | 永不淘汰 | 血条、按钮、通用 UI 背景 |
| 场景层 | 场景切换时重建 | 整体销毁 | NPC 头像、场景独有标记 |
| 动态层 | 帧级管理 | LRU | 队伍成员、动态 buff、活动图标 |
4.3 合批的关键
主场景 UI 性能的核心在于:所有 UI 元素尽量用同一张图集。
假设一个队伍面板:
主角头像(dynamicAtlas) ─┐
队友1头像(dynamicAtlas) ├── 同一张图集 → 一个 batch
队友2头像(dynamicAtlas) ─┘
队伍名背景(permanentAtlas) ← 不同图集 → 多一个 batch
如果所有头像都在 dynamicAtlas 上,Unity 的 Image 组件就可以合批,3 个头像 = 1 个 Draw Call 。如果各在独立的贴图上,3 个头像 = 3 个 Draw Call + 3 次材质切换。
4.4 更新策略
主场景 UI 图标变化不像背包翻页那么"整齐",它是零散的:
- 一个队友上线了 → 加载他的头像,塞入动态图集
- 一个 buff 生效了 → 加载 buff 图标,如果图集满了 LRU 踢一个出去
- 玩家换装备 → 新装备图标塞入、旧图标等待 LRU 淘汰
绝对不要 每次有变化就重建整张图集。图集合并是昂贵的操作(Graphics.CopyTexture 涉及 GPU 同步)。正确的做法是:
- 预先在图中留一些"空位"(padding)
- 新图来了,先找空位塞
- 空位不够了,再 LRU 淘汰 + 覆盖写入
- 图集碎片化严重时(删了太多次),才整张重建整理
五、四种常见方案对比
| 方案 | 算法 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| Unity SpriteAtlas | MaxRects | 静态 UI、固定图集 | 空间利用率高、Editor 原生支持 | 无法处理运行时不确定的资源 |
| Shelf 动态图集(自建) | Shelf + LRU | 背包、道具图标 | 实时性好、实现简单 | 空间利用率偏低 |
| 分层动态图集 | Shelf + LRU + 引用计数 | 主场景 UI | 兼顾常驻和动态、合批效果好 | 需要完善的资源管理系统 |
| 纹理数组(Texture2DArray) | 无打包算法 | 大量同尺寸贴图(图标、头像) | 切换无开销、GPU 友好 | 不支持不同尺寸、需要 Shader 支持 |
为什么纹理数组也是选项:如果你所有头像都是 128×128,用 Texture2DArray 比动态图集更高效------它不需要拼图,GPU 原生支持数组索引切换,零打包开销。
六、总结
动态图集的核心思想只有两句话:
- 空间上------用 Shelf 或 MaxRects 把散图塞进一张大图里,减少 Draw Call
- 时间上------用 LRU 管理缓存,让热图留下、冷图滚蛋
什么时候该用动态图集?
一个简单的判断标准:如果你在设计阶段就确定不了运行时会有哪些图出现在屏幕上,静态图集就管不了你,你需要动态图集。
典型场景就是:
- 背包系统------几千件道具,玩家背包里有什么不确定
- 主场景 HUD------队友、NPC、buff 都是在运行时动态出现的
- 聊天表情/头像------用户可能发任意的表情和头像
- 市场/交易所------海量商品图标
这些场景的共同特征就是本教程开头说的那句话:图片使用不固定,备选资源海量。