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