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”。
要選擇合適的圖片壓縮格式。
