Unity运行时动态图集解决海量物品Icon问题

问题:物件Item数量过千,玩家获取Item的种类顺序不固定。采用传统的预先合图方式,打出的图集数量过多,统一加载过于浪费内存。
版本:2017.4.15f

方案1:每个Item的Icon独立文件,需要时加载并缓存Texture。
这个是首先想到的思路,不过缓存有可能会一直膨胀,需要设立一个缓存上限,并且参考一般LRU Cache的做法,达到上限后删除近期最少使用的Icon Texture。

    //一个基础的带上限的 Texture Pool
    private Dictionary texDict = new Dictionary();
    //用链表表示缓存,这里链表尾部表示最近引用和添加的物品id,链表头部表示最近最少引用的物品id
    //也可以倒过来,这个随意
    private LinkedList lruCache = new LinkedList();
    private const int CacheNumMax = 50;

    public Texture2D GetIconTexture(int id)
    {
        Texture2D tex;
        if (texDict.TryGetValue(id, out tex))
        {
            GetIconInCache(id);
            return tex;
        }

        tex = AddIconTexture(id);
        texDict[id] = tex;

        AddIconToCache(id);
        CheckCacheLimit();

        return tex;
    }

    //清理整个pool
    public void ClearAll()
    {
        foreach (int id in lruCache)
        {
            //destroy gameobject/texture or unload ab
            //...
        }
        //...
    }

    private Texture2D AddIconTexture(int id)
    {
        //从AB或者其他地方加载Texture
        //...
    }

    private void CheckCacheLimit()
    {
        if (lruCache.Count <= CacheNumMax)
        {
            return;
        }

        int id = lruCache.First.Value;
        lruCache.RemoveFirst();
        if (!texDict.ContainsKey(id))
        {
            return;
        }

        //Unload AB或者其他卸载资源方式
        //......

        texDict.Remove(id);
    }

    private void AddIconToCache(int id)
    {
        lruCache.AddLast(id);
    }

    private void GetIconInCache(int id)
    {
        if (lruCache.Remove(id))
        {
            lruCache.AddLast(id);
        }
    }

方案1的劣势在于,由于每个Icon的Texture独立,无法进行有效的Batch,所以尝试进化为方案2。

方案2:开辟一个固定大小的Texture(如1024*1024)图集,运行时将当前要显示的物品Icon像素拷贝到该图集中。图集被拷贝满后,依然按照方案1的缓存上限思路,清除最旧的Icon,腾出位置替换为新Icon。

动态替换的图集

方案2可以分解成两个问题:运行时单个物件Icon在Atlas中申请的位置如何判定(相当于我们自己做一个类似TexturePacker的打包器)以及如何拷贝Icon像素到图集

运行时单个物件Icon在Atlas中申请的位置如何判定:
简单起见,让美术导出Icon的时候固定Icon大小(如100*100),这样一张1024*1024的图集可以分配100个大小相同Rect位置给Icon,且每个Rect的位置(行、列)可以用index/col和index%col简单推导。可以根据实际项目需求调整图集和Icon大小。

这么做的好处还有一个,就是在Atlas填满后,我们直接删除一个最旧的Icon位置,就可以确保新Icon可以直接使用该位置。如果使用不规则的Rect填充Atlas,那可能会导致腾出很多旧Icon位置后依然找不到可以插入的新Icon的位置,降低效率。

跳出当前的话题,在其他一些情况下,如果要打包的图片大小不固定,可以用开源的一些动态合图工具类,比如C#版的MaxRectsBinPack,链接是添加了删除API的版本。

    private readonly MaxRectsBinPack.FreeRectChoiceHeuristic packMethod;
    private readonly MaxRectsBinPack rectsPack;

    //指定合图方式
    packMethod = MaxRectsBinPack.FreeRectChoiceHeuristic.RectBestShortSideFit;
    //初始化大小
    rectsPack = new MaxRectsBinPack(AtlasWidth, AtlasHeight, false);
        
    //Insert一个代表插入图片大小信息的Rect,返回在图集中的位置Rect信息
    Rect newRect = rectsPack.Insert(insertWidth, insertHeight, packMethod);

也可以用该类进行Rect分配,不过就有些杀鸡用牛刀。

如何拷贝Icon像素到图集
Unity的API Graphics.CopyTexture 提供GPU层级的纹理拷贝,速度更快GC也更少。可以通过SystemInfo.copyTextureSupport来判断当前设备是否支持该API。如果不支持,用GetPixels和SetPixels来读取拷贝。

Unity的文档中说明Android设备得支持OpenGL ES 3.1才能使用该API。高通Adreno系列从Adreno 405开始支持,系列参数可以参考官网。华为用的Mali GPU从T系列开始支持,对照华为的Kirin各代方案配置表和Mali的Wiki,除了初代的Kirin620和910,基本全系列支持OpenGL ES 3.1。

为了对拷贝速度和GC有一个比较直观的认识,我们来Android真机做一个测试,用上述两种方式拷贝一百个100*100的Texture填满到一张1024图集中,用Profiler来对比结果。样机为红米8A,去年低端机,骁龙439+Adreno505,支持OpenGL ES 3.1。


用Graphics.CopyTexture

用GetPixels和SetPixels

用Graphics.CopyTexture把时间压缩到了3ms,也就是说可以控制在单帧内,而且mono零gc,这个速度还是相当可观的,对于玩家来说几乎不存在卡顿,在对draw call敏感的一些问题上可以发散出很多思路。

用GetPixels和SetPixels要注意图片资源需要打开Read/Write Enable,不然会得到一堆灰块。并且会在Mono产生比较高的GC。主要就是GetPixels读取出的Color数组,如果图片像素数比较多,大小甚至会达到几十mb。如果要比较好的支持非OpenGL ES 3.1设备,可以考虑用Native写到C++里。

经过上面两个步骤,我们得到了一张Texture2D合图,和每个Icon在合图上的位置大小信息,也就是uv值。如果用的是FairyGUI,可以参考其内部图集NTexture的实现,将每个位置用一个子NTexture表示(因为图片大小固定的话,位置也是固定的,就不用每次申请的时候new一个新的,可以减少GC)。将子NTexture赋给GLoader,就可以显示出对应的图标。

现在我们把DrawCall降到了1,图片像素拷贝时间也可以忽略不计。但在一些极限情况,比如FairyGUI里面虚拟列表VirtualList快速滚动时,很短的时间间隔内要不断地读取十到二十个新的Icon文件到图集中,纹理拷贝前的ab读取和文件IO过程变成了新的瓶颈。

方案2.5:异步加载和显示
用户的操作不能被中断,所以在Icon位置可以考虑先显示空白或者一个统一图标,异步加载完纹理后再做拷贝和显示。

Unity在2017版本里支持.Net 4.x的API,可以利用async/await关键字代替unity的协程进行异步操作,Unity的一些原生异步API比如AssetBundle.LoadFromFileAsync可以通过TaskCompletionSource封装成支持await的API。

public async Task SetIcon(GLoader iconLoader, string iconName)
{
    if (iconLoader.texture != null)
    {
        iconLoader.texture = EmptyNTexture; //可是留白或者加载动画
    }

    iconLoader.data = iconName;

    //SetIcon函数要确保可以被重复调用,把未完成的Task先存储起来,防止异步操作可能带来的bug,比如在加载完成前就被销毁等等。
    Task task;
    Rect rect;
    if (taskDict.TryGetValue(iconName, out task))
    {
        rect = await task;
    }
    else
    {
        task = iconAtlas.GetItemIconRect(iconName);  //利用AssetBundle.LoadFromFileAsync异步加载ab资源
        taskDict[iconName] = task;
        rect = await task;
    }

    taskDict.Remove(iconName);

    if (rect == Rect.zero)
    {
        return;
    }

    if ((string)iconLoader.data != iconName)
    {
        return;
    }

    //FairyGUI的region原点(0,0)在左上角,要做一次转化
    Rect uvRegion = new Rect(rect.x, Texture.height - rect.y - rect.height, rect.width, rect.height);
    iconLoader.texture = GetNTexture(uvRegion); //直接把缓存的NTexture赋给GLoader
}

实机测试下来,在一些低端机型上也能保证比较良好的体验。如果是针对国内市场的项目,可以借助OpenGL ES 3.1的优势,减少UI上的DrawCall,把钢省在刀刃上,提升其他方面的画质和体验。

你可能感兴趣的:(Unity运行时动态图集解决海量物品Icon问题)