Unity运行时动态图集的实现


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回收再利用那块区域,所以它不是一个动态图集,应该叫它动态打图集或者运行时打图集。

本文目的:

  1. 基于该算法,实现一整套“加载-资源管理-拷贝Texture-显示”等流程。
  2. 实现新的算法,可以持久化图集,增加回收无引用空余区域。
  3. 优化代码,扩展更多的功能。增加多种机制,使项目组可以灵活使用。

 

2 | 功能如何实现

1. 创建Image控件

这里提供两种方式:一种是UIDynamicImage,继承自Image;一种是UIDynamicRawImage,继承自RawImage。

2. 设置参数

  1. Group:在一个什么组的图集中改图片?256/512/1024/2048,不同的组别对应不同的图集大小,根据项目组需求选择合适的组别;太小的话容易不够,DA会另外再生成一个同样大小的图集,这样就会导致你的DrawCall至少会变成2个;太大的话(比如2048),会相对地占用一定的内存。

  2. 其它配置,是否使用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种模式

  1. Point:点过滤,采样点选取距离最近的texel(像素点)
  2. Bilinear:双线性过滤,采样点选取周围4个texel,取平均值作为采样点
  3. Trilinear:三线性过滤,并且在Mipmap之前进行线性过滤

Trilinear不选择,因为我们的UI不设置Mipmap。

根据其计算原理,我们得到使用Point的场景:

  1. 当图片的宽高小于或者等于图片原始尺寸的时候
  2. 当图片中有像素差异特别大的时候

但是使用线性过滤也有问题,看一下我们动态图集的方式就知道,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”。

要选择合适的图片压缩格式。

 

 


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM