1 | 前言
一、应用场景
1. 偏重度游戏,对Draw Call有降低要求的场景。
如果不是很重度的游戏,比如轻游戏,那本身渲染压力就不大,没必要斤斤计较,只需快速迭代。
2. 图片资源太多,以至于没办法全部打成一个图集。
常见的,比如MOBA游戏或者MMORPG游戏中使用技能的那块UI区域,有人说可以将所有的相关资源打成一个大图集,这样不就是1个Draw Call了吗?这样确实可以实现1个Draw Call,但是后期热更新的时候,如果修改或者添加一张图片资源,那么就意味着要更新整个图集的资源。
还有人说可以根据类别来打图集,比如每个英雄的资源是一个图集,其它common资源,比如公用技能是一个图集,这样折中的办法可以降低热更新的成本。使用这种方式的问题是在前期可能还挺好的,或者在开发阶段一点问题都没有,但是common资源会增加到什么程度呢?随着运营时间的增长,功能的增多,图片可能2048*2048都装不下,然后就只能多个common。
其次由于参与的图集比较多,还有不同的Text,为了合并Draw Call不被打断,要小心地设置UI的层级,避免重叠等问题造成合批被打断,这也是一个工作量。随着功能越来越多,难度可能成指数级增长,具体UGUI是怎么计算Draw Call的,我之前在UWA学堂的《详解UGUI DrawCall计算和Rebuild操作优化》一文中有讲过,大家可以看一下。
二、什么是动态图集
所谓动态图集,就是在游戏运行过程中,生成一张或n张较大的Texture图集(根据项目需求,可以设置Texture大小为1024*1024或2048*2048等等),Image加载进来的Texture不直接使用,而是将Texture信息按照一定规则和排列拷贝到这张Texture上,以达到n个Image共用一张Texture,合并Draw Call的目的,同时Image销毁不用之后,Texture信息从大的Texture图集中移除,达到图集空间重复利用的目的。
为了以后说这个系统更方便,给这个系统取个名字叫DynamicAtlas,简称“DA”。DA的思路:本文思路来源于《小米超神》技术总监王啸予:《重度MOBA手游的优化之路》,里面讲了他们的游戏用到了动态图集技术,灵感又来源于一个开源项目Runtime Sprite Sheets Generator。
这个开源的Demo主要讲了一个算法,即在一个指定大小的区域,有若干个小矩形,怎么样让它们排列得更好、更整齐?就像下图展示的样子。
然而我并没有继续进行下去,因为我觉得它还可以继续扩展。按照这种算法,这种方式也是一种一次性的图集,也就是只能追加,不能将已添加的Texture回收再利用那块区域,所以它不是一个动态图集,应该叫它动态打图集或者运行时打图集。
本文目的:
- 基于该算法,实现一整套“加载-资源管理-拷贝Texture-显示”等流程。
- 实现新的算法,可以持久化图集,增加回收无引用空余区域。
- 优化代码,扩展更多的功能。增加多种机制,使项目组可以灵活使用。
2 | 功能如何实现
1. 创建Image控件
这里提供两种方式:一种是UIDynamicImage,继承自Image;一种是UIDynamicRawImage,继承自RawImage。
2. 设置参数
- Group:在一个什么组的图集中改图片?256/512/1024/2048,不同的组别对应不同的图集大小,根据项目组需求选择合适的组别;太小的话容易不够,DA会另外再生成一个同样大小的图集,这样就会导致你的DrawCall至少会变成2个;太大的话(比如2048),会相对地占用一定的内存。
- 其它配置,是否使用CopyTexture,你可以设置一个默认,或者游戏中动态设置;以及设置游戏中Android和iOS图片格式。
public class AtlasConfig { public static bool kUsingCopyTexture = false;//是否使用CopyTexture接口 #if UNITY_ANDROID public const TextureFormat kTextureFormat = TextureFormat.ARGB32;//android,ios的图片格式选择 #else public const TextureFormat kTextureFormat = TextureFormat.ARGB32; #endif #if UNITY_ANDROID public const RenderTextureFormat kRenderTextureFormat = RenderTextureFormat.ARGB32;//android,ios的图片RenderTextureFormat #else public const RenderTextureFormat kRenderTextureFormat = RenderTextureFormat.ARGB32; #endif }
3. 如果有预设置的UI
这部分也要走DA(不用手动写代码设置),那么直接将该图片拖上去,跟用正常的Image和RawImage一样,DA会帮你将该资源打到Atlas上去。
4. 写好接口代码
public delegate void OnCallBackTexRect(UnityEngine.Texture2D tex, UnityEngine.Rect b); public void SetImage(string path, OnCallBack callBack = null) public void LoadResourceAsync(string filePath, OnCallBackSObject callBack, System.Type systemTypeInstance)
1、获取控件,调用SetImage,传入图片路径以及回调。 * path:路径或者名字,该参数其实是跟LoadResourceAsync有关,即你的项目是如何去加载资源有关,如果你的项目是根据文件名映射了全地址,就传文件名,或者你的项目传的就是全地址。 2、所以LoadResourceAsync 这个还是要你自己来写,这里就不提供加载资源的解决方案了。
5. 点击Show DynamicAtlas的按钮
运行之后,在UIDynamicImage或者UIDynamicRawImage控件下方会出现Show DynamicAtlas的按钮,点击它,出现一个Window。这里显示了当前这个动态图集到底长什么样,以及分布情况。游戏关闭之后,该Window就会被关闭。
- 右上角的slider控制当前Window中展示图集的缩放。
- ShowFreeAreas勾上之后就会显示具体FreeArea。
- RefreshAndClearFreeArea按钮则会做两件事。第一件事是RefreshFreeArea,刷新了FreeAreas的位置;第二件事是Clear FreeAreas,也就是回收了的Texture以前占有的那个区域会被Clear掉,这样方便我们查看当前还有哪些图片在图集中。



3 | 动态图集的思路以及实现原理
一、基本步骤
1. 整体思路
(1)初始化创建一个Texture2D(以下简称TexAtlas)
(2)逻辑层Image控件GetImage
(3)根据Texture的地址加载得到Texture2D
(4)根据Texture2D的宽和高,在TexAtlas找到合适大小和位置的区域,将此Texture2D渲染到TexAtlas上
(5)返回TexAtlas以及UVRect

二、关键痛点解析
1. SetImage与SetImageNoHide
(1)UIDynamicRawImage.SetImage UIDynamicRawImage.SetImageNoHide,首先这两个参数的区别是SetImage调用的时候,会先将控件隐藏,等到Callback Image之后显示,而SetImageNoHide则不会。
(2)DynamicAtlas.GetImage DA中获取Image的入口,先检查缓存,缓存没有则起一个Task,然后进行队列处理Task,当然你也可以去设置同时进行Task的数量,修改if(mGetImageTasks.Count > 1)的这个数字1就行了。
public void GetImage(string path, OnCallBackTexRect callback) { if (_usingRect.ContainsKey(path)) { if (callback != null) { SaveImageData imagedata = _usingRect[path]; imagedata.referenceCount++; Texture2D tex2D = m_tex2DList[imagedata.texIndex]; callback(tex2D, _usingRect[path].rect, path); } return; } GetImageData data = DynamicAtlasManager.Instance.AllocateGetImageData(); data.path = path; data.callback = callback; mGetImageTasks.Add(data); if (mGetImageTasks.Count > 1) { return; } OnGetImage(); }
2. 创建一个什么样的TexAtlas?
- 创建方式:
Texture2D tex2D = new Texture2D(mWidth, mHeight, TextureFormat.RGBA32, false, true);
- no Mip maps,不用多说。
- filterMode选择Bilinear,不选择Point,是因为我们不想图片有颗粒感,不平滑,而我们没有Mip maps则也不会选择Trilinear。
- TextureFormat RGBA32或者区分平台使用可以缩的格式,比如iOS PVRTC或ASTC,Android使用ETC1或者ETC2。
我这里建议使用RGBA32,首先来看内存,我们常用的1024x1024的RGBA32占有了多少内存:1024x1024x4Bytes = 4MB,即使是一个2048x2048的RGBA也是占用16MB,如果是大型游戏,这个大小就不算多。
其次,我们无法确定我们的手机GPU是否支持设定的图片格式。因为手机如果不支持ETC2,Unity会将图片格式转成基础的RGBA,所以要选择合适的Texture2D作为图集使用。
第三,加载进来的Texture2D在渲染到TexAtlas之后就会销毁,不会留在内存,所以也不用担心占用内存太多。
3. 怎样渲染到TexAtlas上?
将一个Texture2D渲染到另外一张Texture2D有四种方法:
(1)Graphics.Blit(Texture source,RenderTexture dest,Material mat),high-level library与之对应的是GL:high-level library,按照Unity的说法优化了。
- 原理:使用Shader进行复制Texture2D。
- 缺点:大材小用,仅仅是复制Texture就要调用GPU结构走一遍渲染管道流程,还是太重了。Graphics.Blit一般用于后处理那种复杂功能。
(2)GL编程 Low-level graphics library,作用跟Graphics.Blit差不多,但是实现起来比较麻烦,也就是代码较多,Graphics.Blit上文已提过,但是由于目前手机数量有限,无法得知或者确定Graphics.Blit一定比GL快。我用小米4测试,GL反而没有明显CPU延迟,所以两种函数都提供了,大家可以有选择性地使用。
(3)Texture2D的 Color[] GetPixels()以及 void SetPixels(int x, int y, int blockWidth, int blockHeight, Color[] colors);
- 优点:相对Graphics.Blit函数消耗少了很多,CPU层面和GPU层面不是很费性能。
- 缺点:调用GetPixels的时候,这个Texture2D图片必须是可读写,也就是要勾选Read/Write Enabled,这就意味着这个Texture2D图片要在CPU和GPU中各占一份内存,虽然说这个Texture2D用完之后就会卸载掉,但是如果有更好的办法,这个还是不可取的,而且项目组确实也没有将一个Texture2D勾选为可读写的习惯。
(4)Graphics.CopyTexture
- GPU级别的接口函数,跟CPU基本没什么关联,所以无论是效率还是内存,都是最优解。然后事实上,并不是所有手机的GPU都支持这个接口,通过SystemInfo.copyTextureSupport接口可以得知GPU是否支持这个接口,而且有的格式的图片,也不支持Copy,比如PVRTC这种不是基于块的格式,所以限制性较大。
void GLBlit(Rect rc, Texture source, RenderTexture dest, Material fxMaterial, int passNr) { Rect uv = new Rect( 0, 0, 1, 1 ); dest.MarkRestoreExpected(); Graphics.SetRenderTarget(dest); fxMaterial.SetTexture("_MainTex", source); GL.PushMatrix(); GL.LoadOrtho(); fxMaterial.SetPass(passNr);//Activate the given pass for rendering GL.Begin(GL.QUADS); GL.TexCoord2(uv.xMin, uv.yMin); GL.Vertex3(rc.xMin, rc.yMin, 0.1f); // BL GL.TexCoord2(uv.xMax, uv.yMin); GL.Vertex3(rc.xMax, rc.yMin, 0.1f); // BR GL.TexCoord2(uv.xMax, uv.yMax); GL.Vertex3(rc.xMax, rc.yMax, 0.1f); // TR GL.TexCoord2(uv.xMin, uv.yMax); GL.Vertex3(rc.xMin, rc.yMax, 0.1f); // TL GL.End(); GL.PopMatrix(); } void GraphicsBlit(Rect rc, Texture source, RenderTexture dest, Material fxMaterial, int passNr) { fxMaterial.SetVector( mBlitParamId, new Vector4( rc.xMin, rc.yMin, rc.xMax, rc.yMax ) ); #if UNITY_EDITOR dest.DiscardContents(); #endif Graphics.Blit( source, dest, fxMaterial ); } private void CopyTexture(int posX, int posY, int index, Texture2D srcTex) { Texture2D dstTex = m_tex2DList[index]; Graphics.CopyTexture(srcTex, 0, 0, 0, 0, srcTex.width, srcTex.height, dstTex, 0, 0, posX, posY); }
所以该怎么选择呢?我个人建议,如果你的游戏比较优质,支持的低端机都至少是iPhone7,选择的图片格式也是ASTC这种好的压缩格式,那就用Graphics.CopyTexture;如果不支持Graphics.CopyTexture的,选择Graphics.Blit或者GL都行。
4. 将Texture2D画到TexAtlas的什么位置上?
找到TexAtlas的FreeArea,什么是FreeArea,下面先看图:
* 根据图中我们看到,初始化没有图片的时候,整个1024*1024的矩形区域都是FreeArea,当加载完一张图的时候,这个矩形被拆分成三部分,一部分是图片占据的有用区域,另两部分就是FreeArea了。
将一个矩形拆分成三部分,一份InsertArea,两份FreeArea,并将数据存储。
5. 怎么回收Image的Texture?
首先我们是不擦除已经画上去的Texture数据的,毕竟擦和画一样,都有一定的损耗。我们只是把标记为InsertArea容器中的数据清除并且将这个区域添加到FreeAreas中。从第4问中我们已经知道了怎么切的,之所以这样切是因为这种切法切的最少,同时最重要的是能够合并回原始矩形。
回收方法:DynamicAtlas.OnMergeAreaRecursive中,具体代码就不贴了,文章第5节会提供Demo代码下载,可以前往查看,这里只说思路,先看图。
一个矩形被拆分成三部分。当然,特殊情况下当图片的宽或者高正好和矩形的宽或者高相等时,矩形会分成两部分,还有一种情况是矩形正好和图片宽高相同,这个就没有分割之说了。不管矩形被分成3份还是2份,算法是一样的,看上图,我们遍历FreeAreas,找到图中2区域,怎么找?答:两个矩形,posY值相等,高相等,一个矩形的right(right = posx + width)跟另一个矩形的left(posx)相等,那么这个矩形就是另一个矩形的右矩形,如果右矩形找到了,那么就合并这两个矩形;如果找不到,就去找上矩形,左矩形,下矩形,如果都找不到,就结束寻找。
为什么会有找不到的情况?看下面的图,当要回收1的时候,是找不到它的右上左下矩形的,只有5,4,3,2都移除之后,1才能合并矩形,并且之后跟上面的大块矩形合并。
6. 当一张图集没有位置了怎么办?
尽量避免这种情况,如果出现了,说明你可能已经过度使用动态图集了,或者设置的图集尺寸太小,不足以满足大部分的情况,又或者是将过大的图片加入到动态图集中了。
但是依然为大家提供了解决方案,就是再次创建一个同等大小的图集,以供使用。后果就是溢出的Texture无法跟之前图集中的Texture合批,但是也不用过于担心,不过是+1个DrawCall的问题。如果你的UI排列不好,依然会出现多个打断不能合批的情况。关于Draw Call合批原理,请参见我的另外一篇UWA学堂文章《详解UGUI DrawCall计算和Rebuild操作优化》。
4 | 功能优化与扩展
一、减少GC以及增加效率的优化
1. 减少GC减少new的操作?
为了让游戏更少的GC,我们采用allocateRectangle & freeRectangle概念,就是将数据结构装进池中,经过出池->使用->回收->入池的过程,重复利用了数据结构,避免过度的申请内存。
public IntegerRectangle AllocateRectangle(int x, int y, int width, int height) { if (mRectangleStack.Count > 0) { IntegerRectangle rectangle = mRectangleStack.Pop(); rectangle.x = x; rectangle.y = y; rectangle.width = width; rectangle.height = height; return rectangle; } return new IntegerRectangle(x, y, width, height); } public void ReleaseRectangle(IntegerRectangle rectangle) { mRectangleStack.Add(rectangle); }
之前说过为何推荐使用Graphics.CopyTexture这个函数,是因为这个拷贝操作是发生在GPU,而不是CPU,我们并没有让图片有勾选Read/Write Enable的操作,所以CPU是拿不到这个Texture内存的,也不会有多余的申请内存操作,有的只是Texture本身占有的内存。
2. 切割FreeArea的过程中,有哪些方式能够提高利用率?
(1)当优先宽的时候,插入新的图片,优先找高最小的FreeArea,反之找宽最小的。
(2)当切一个矩形的时候,其实有两种切法——优先宽或者优先高。
(3)当优先高的时候,如果图片的高足够小,那么切割的3个区域,其中的上边的区域会足够大,能够保证剩余的区域都能有足够大的区域,以防当一个大的图片加入到图集时,没有大区域存放而不得不开辟一个新的图集。
(4)在优先高的时候,当新的图片来存放,尽量给它放到适合的FreeArea中高最小的,从而能够最大化地合理利用空间。(可以参考第5问中的第二张图。)
二、怎么选择图片的Filter Mode的类型?
前面稍微提到一点,这里详细说一下,为什么会有Filter Mode,我们知道一张图片是可以进行放大缩小的,即使一个10x10像素的图片,也可以拉伸成100x100,那么就有一个问题,100x100这个大的采样点该选择哪个像素点进行采样呢?
Mode有3种模式:
- Point:点过滤,采样点选取距离最近的texel(像素点)
- Bilinear:双线性过滤,采样点选取周围4个texel,取平均值作为采样点
- Trilinear:三线性过滤,并且在Mipmap之前进行线性过滤
Trilinear不选择,因为我们的UI不设置Mipmap。
根据其计算原理,我们得到使用Point的场景:
- 当图片的宽高小于或者等于图片原始尺寸的时候
- 当图片中有像素差异特别大的时候

但是使用线性过滤也有问题,看一下我们动态图集的方式就知道,Texture引用计数为0的时候我们不去clear这块区域,主要是太费了,看下图:

问号边缘处明显跟问号的像素不一样,这样我们使用线性过滤进行采样的时候,就会有那么1像素是不对的,造成穿帮。

解决办法是将UV的宽和高减1像素。因为只要把那1像素有问题的像素剪了就可以省去所有的麻烦,不需要擦除脏的轮廓,因为这个操作很费内存。减少1像素对于整个图片来说也无所谓,除非是那种只有3*3像素的九宫格,那么这种情况,我建议你在美术出图的时候,给它的外边加一个1像素的透明来抵消这一像素。
Rect uv = new Rect(useArea.x * mUVXDiv, useArea.y * mUVYDiv, (useArea.width - mPadding - 1) * mUVXDiv, (useArea.height - mPadding - 1) * mUVYDiv);
三、关于动态图集与已有UI进行合批
1. 怎么让动态图集中的元素与预先拼好UI的Texture进行合批?
(1)之所以有这个需求,是因为经常有这样一个场景:动态变化的图片通常是有一个底或者是框或者是背景图,上面一般有状态图片,这样这个动态图集必然会打断合批。而像下面这张图,MOBA游戏最主要的是流畅度,必然会尽量地去减少Draw Call。文章最开始也说了,都打成一个图集又不现实,不打又会造成很多Draw Call,所以我们有需求让预先拼好的那些图和我们的动态图集中的图合成1个Draw Call。
(2)首先我们正常地去拼页面。如图所示,这样这个UIDynamicImage就会依赖一个Sprite,在游戏运行之后,加载这个控件(无论是用resource还是AssetBundle加载)都会去加载它的依赖,这样在Start()生命周期中拿到Sprite中的(Texture2D)mainTexture,之后就像调用GetImage()方法中的渲染Texture的方法一样去渲染就可以了。
- 优点:前面说到了,它可以事先拼UI,合Draw Call。
- 缺点:这里是有一定的浪费的,首先从Image中拿已有的Texture2D,拷贝到图集中,这比正常过程多了一步,我们做一个对比。
可以看出多出来初始的Texture赋值给Image的操作,而这个操作其实做的是将数据传递给GPU,所以会费一些,但是其实还好。
四、运行时不可回收图集PackingAtlas原理
- 既然是PackingAtlas那就简称PA吧。
- 在上面提到的,这篇文章灵感来源于那个Runtime Sprite Sheets Generator,所以我花了一些功夫研究它是怎么实现的。
-
- 同时我也将原代码做了修改和封装,但是原理不变。
-
- 切割方式,如上图,一个矩形,切割成三部分,跟可回收图集不同,这个切割方式是这样的,它切割之后,2个FreeArea是有重叠的,这样的好处就是新的切割区域可以最大可能地利用FreeArea,而不像DA,切割了一个大的和一个小的,小的利用率几率比较小。如果DA中,大的都切割完了只剩下小的,那再来一个大的将会无处安放,所以PA的好处就是利用率高。
- 但是这种切法的缺点是不可回收,因为经过多次切割之后,没有任何规律可寻,无法合并矩形,也就无法还原,所以这种方式叫PackingAtlas。
Vector2 range = m_FindRange[index];
if (result.right > range.x)//尽量的往左,往高了找 range.x = result.right; if (result.top > range.y) range.y = result.top;
- 每次在找完区域得到result之后,都要更新range的x和y,而下次找新的切割区域的时候都会在FreeArea中优先去找符合条件的,也就是free.x < range.x || free.y < range.y,这样做的好处就是区域可以很整齐。
5 | 动态图集的总结以及注意点(附Demo工程)
其实这个文章我准备了很久,因为之前项目有写过,也用过,所以觉得写这个文章应该很快。但是真正开始写的时候才发现,为了让这个Demo更通用,必须要提供全部实现方式(能力有限,只能是我认知范围的全部),同时还要去加以说明各自的优缺点,这跟做项目不一样,做项目只要实现就行了,做Demo需要想的多一些。比如最开始是使用Graphics.Blit来拷贝图片,但是不得不提供其它三种拷贝图片的方式;比如最开始只有RawImage,但是必须有Sprite;又比如对于CopyTexture以及RenderTexture的方式,既然要作区分,就要有标志位,所以又反过来做了很多的判断。
在展示当前DA的图集状态时,也想过很多种方式,最后才确定使用Editor Windows的方式,但是在手机就看不到了。那么手机上有没有想到好的办法,可能出一个Debug盒?但是这又跟本文没多少关系,大家可以根据项目情况,考虑是否扩展这一功能。
在考虑回收的算法的时候,也想过很多种算法,最后才选定一个自认为不错的方案,如果读者有更好的算法,请在评论处留言,大家一起探讨。
因为这是一个Demo,所以无法预知也无法提供,也没必要提供一些模块的架构,比如UI模块加载、资源加载于释放、AesstBundle相关。本文重点说了DA的用法,所以各位读者在拿到这个Demo之后还需要结合自身项目进行更改。
动态图集所用的资源格式必须跟图集的格式一样,不然会报这个错:Graphics.CopyTexture can only copy between same texture format groups (d3d11 base formats: src=0 dst=27)。
前面已经说了,为了防止外圈有一像素的脏像素,UV向里减了1像素,所以在使用的时候要注意。当然,如果你要使用FilterMode.Point这个图片模式,那就不用了。
要根据自己项目实际情况选择是否要开启AtlasConfig.kUsingCopyTexture,CopyTexture这个函数如果不支持会报“called unimplemented OpenGL ES API”。
要选择合适的图片压缩格式。