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

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

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

    //一个基础的带上限的 Texture Pool
    private Dictionary<int, Texture2D> texDict = new Dictionary<int, Texture2D>();
    //用链表表示缓存,这里链表尾部表示最近引用和添加的物品id,链表头部表示最近最少引用的物品id
    //也可以倒过来,这个随意
    private LinkedList<int> lruCache = new LinkedList<int>();
    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。如果不支持,用GetPixelsSetPixels来读取拷贝。

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<Rect> 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,把钢省在刀刃上,提升其他方面的画质和体验。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335