问题:物件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把时间压缩到了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,把钢省在刀刃上,提升其他方面的画质和体验。