Unity动态图集方案

Unity动态图集方案

前言:什么时候需要动态图集?

先想两个场景:

场景 A:背包 ------ 玩家背包里可能有 200 件道具,每件道具的图标都不一样。你不知道玩家会开出什么装备、会合成什么材料。不可能把所有道具图标都塞进一张静态图集里------塞不下,也浪费内存。

场景 B:主场景 UI ------ 大地图上显示玩家头像、队伍成员头像、任务 NPC 头像、附近玩家头像。这些头像可能是玩家自定义的、工会徽章、不同怪物的贴图。同样,你没法预先把所有可能出现的头像绑到一张图集里。

这两个场景的共同特征是:用哪些图是运行时才能确定的,候选资源是海量的

静态图集解决不了这个问题,所以需要 动态图集 ------ 在运行时动态地把散图拼成一张大纹理,按需加载、按需淘汰。


一、核心算法:矩形装箱

所有图集方案底层都绕不开一个几何问题:怎么把一堆大小不一的矩形,尽可能紧凑地塞进一张大矩形里?

1.1 MaxRects(最大矩形法)

这是 Unity SpriteAtlas 打包工具底层用的核心算法

思路非常简单:

  1. 一开始你有一张空白大图(比如 2048×2048),这就是一个"空白矩形"
  2. 放第一张小图进去,剩余空间被切成几个"空白矩形碎片"
  3. 放下一张图时,在所有空白矩形里找一个最能塞下它的,塞进去
  4. 塞完后剩下的碎片接着留着用

类比切蛋糕:你有一整块蛋糕(图集),要切出各种形状的块(小图)。每切一块,剩下的蛋糕碎块你接着装盘。MaxRects 的追求是:哪块碎蛋糕最不浪费,我就在哪切。

优点 :空间利用率极高,通常能达到 90%-95%

缺点:每次都遍历所有"空白矩形",CPU 开销大

为什么 Unity 打包工具敢用 MaxRects? 因为它是离线操作,只在打 AssetBundle 的时候跑一次,不在乎那几毫秒的 CPU 时间。

1.2 Shelf / Skyline(货架法)

这是运行时动态图集最常用的算法,也是 Unity 早期版本内部推荐的做法。

思路换了个角度:

  1. 从大图底部开始画一条"天际线"
  2. 每来一张小图,从左往右找天际线上最矮的位置塞进去
  3. 塞完后,天际线被更新

类比整理衣柜:你把 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 同步)。正确的做法是:

  1. 预先在图中留一些"空位"(padding)
  2. 新图来了,先找空位塞
  3. 空位不够了,再 LRU 淘汰 + 覆盖写入
  4. 图集碎片化严重时(删了太多次),才整张重建整理

五、四种常见方案对比

方案 算法 适用场景 优点 缺点
Unity SpriteAtlas MaxRects 静态 UI、固定图集 空间利用率高、Editor 原生支持 无法处理运行时不确定的资源
Shelf 动态图集(自建) Shelf + LRU 背包、道具图标 实时性好、实现简单 空间利用率偏低
分层动态图集 Shelf + LRU + 引用计数 主场景 UI 兼顾常驻和动态、合批效果好 需要完善的资源管理系统
纹理数组(Texture2DArray) 无打包算法 大量同尺寸贴图(图标、头像) 切换无开销、GPU 友好 不支持不同尺寸、需要 Shader 支持

为什么纹理数组也是选项:如果你所有头像都是 128×128,用 Texture2DArray 比动态图集更高效------它不需要拼图,GPU 原生支持数组索引切换,零打包开销。


六、总结

动态图集的核心思想只有两句话:

  1. 空间上------用 Shelf 或 MaxRects 把散图塞进一张大图里,减少 Draw Call
  2. 时间上------用 LRU 管理缓存,让热图留下、冷图滚蛋

什么时候该用动态图集?

一个简单的判断标准:如果你在设计阶段就确定不了运行时会有哪些图出现在屏幕上,静态图集就管不了你,你需要动态图集。

典型场景就是:

  • 背包系统------几千件道具,玩家背包里有什么不确定
  • 主场景 HUD------队友、NPC、buff 都是在运行时动态出现的
  • 聊天表情/头像------用户可能发任意的表情和头像
  • 市场/交易所------海量商品图标

这些场景的共同特征就是本教程开头说的那句话:图片使用不固定,备选资源海量

相关推荐
元气少女小圆丶4 小时前
停止单机,开始学一点后台通信吧
unity
丁小未5 小时前
Unity高效的DOTS动画方案Rukhanka深度剖析
unity·游戏引擎·rukhanka·dots动画·gpu动画·gpuecsanimation
玖玥拾1 天前
Unity 3D 基础(一)Unity基础架构、物理系统、视觉渲染、空间变换与对象查询
笔记·游戏·3d·unity·游戏引擎
玖玥拾3 天前
C# 语言进阶(十四)Unity UI界面开发 + 网络消息联动
服务器·开发语言·网络·ui·unity·c#·游戏引擎
野区捕龙为宠3 天前
Unity 持久化数据
unity·游戏引擎·lucene
郝学胜-神的一滴3 天前
[简化版 GAMES 101] 计算机图形学 17:纹理技术从基础原理到多场景实战应用
c++·unity·游戏引擎·图形渲染·three.js·opengl·unreal
_ZHOURUI_H_4 天前
MyFramework: 同样是 Unity 游戏开发框架,和 HTFramework 的设计取向有什么不同?(下)
unity·游戏引擎·游戏开发·游戏ui·手游开发