Unity 分離貼圖 alpha 通道實踐

indienova 發佈 2020-08-13T14:28:08+00:00

一般來說,目前 Unity 的手機遊戲 android 上非透明貼圖會使用 RGB Compressed ETC 4bits,透明貼圖可以使用 RGBA Compressed ETC2 8bit,iOS 非透明貼圖使用 RGB Compressed PVRTC 4bits,透明貼圖使用 RGBA Compressed PVRTC 4bits。

引言

在做手機遊戲時可能會遇到這些問題:

  • UI 同學天天抱怨 iOS 上一些透明貼圖壓縮後模糊不堪

  • 一些古早的 Android 手機上同樣的貼圖吃內存超過其他手機數倍,遊戲經常閃退

這篇文章給出了一種手機遊戲項目中通用的解決方案:分離貼圖 alpha 通道,及其基於 Unity 引擎的實現過程和細節。其中思路主要來自於 https://zhuanlan.zhihu.com/p/32674470,本文是對該方法的實踐和補充。

為什麼要分離

1. 為什麼會出現這些問題

要弄明白這些問題的由來,首先要簡單解釋一下貼圖壓縮格式的基礎概念。

為了讓貼圖在手機中運行時占用儘可能少的內存,需要設置貼圖的壓縮格式,目前 Unity 支持的主要壓縮格式有:android 上的 ETC/ETC2,iOS 上的 PVRTC,以及未來可能會使用的 ASTC。這幾個壓縮格式有自己的特點:

  • ETC:不支持透明通道,被所有 android 設備支持

  • ETC2:支持透明通道,Android 設備的 GPU 必須支持 OpenGL es 3.0 才可以使用,對於不支持的設備,會以未壓縮的形式存在內存中,占用更多內存

  • PVRTC:所有蘋果設備都可以使用,要求壓縮紋理長寬相等,且是 2 的冪次(POT,Power of 2)

  • ASTC:高質量低內存占用,未來可能普遍使用的壓縮格式,現在有一部分機型不支持

一般來說,目前 Unity 的手機遊戲 android 上非透明貼圖會使用 RGB Compressed ETC 4bits,透明貼圖可以使用 RGBA Compressed ETC2 8bit,iOS 非透明貼圖使用 RGB Compressed PVRTC 4bits,透明貼圖使用 RGBA Compressed PVRTC 4bits。

這裡的 bits 概念的意思為:每個像素占用的比特數,舉個例子,RGB Compressed PVRTC 4bits 格式的 1024x1024 的貼圖,其在內存中占用的大小 = 1024x1024x4 (比特) = 4M (比特) = 0.5M (字節)。

我們可以看到,在 iOS 上,非透明貼圖和透明貼圖都是 4bpp(4bits per pixel)的,多了透明通道還是一樣的大小,自然 4bpp 的透明貼圖壓縮出來效果就會變差,而實機上看確實也是慘不忍睹。這是第一個問題的答案。

一些古早的 android 機,由於不支持 OpenGL es 3.0,因此 RGBA Compressed ETC2 8bit 的貼圖一般會以 RGBA 32bits 的格式存在於內存中,這樣內存占用就會達到原來的 4 倍,在老機器低內存的情況下系統殺掉也不足為奇了。這是第二個問題的答案。當然,需要說明的是,現在不支持 OpenGL es 3.0 的機器的市場占有率已經相當低了(低於 1%),大多數情況下可以考慮無視。

更多的貼圖壓縮格式相關內容可以參考這裡:https://zhuanlan.zhihu.com/p/113366420

2. 如何解決問題

要解決上面圖片模糊的問題,可以有這些做法:

  • 透明貼圖不壓縮,內存占用 32bpp

  • 分離 alpha 通道,內存占用 4bpp+4bpp(或 4bpp+8bpp)

不壓縮顯然是不可能的,畢竟 32bpp 的內存消耗對於手機來說過大了,尤其對於小內存的 iOS 設備更是如此。所以我們考慮分離 alpha 通道,將非透明部分和透明部分拆成兩張圖(如下所示)。

至於其內存占用,一般來說會把非透明部分拆成 RGB Compressed PVRTC 4bits,而透明通道部分可以使 RGB Compressed PVRTC 4bits,也可以是 Alpha8 格式(8bpp)。Alpha8 格式似乎不同版本 Unity 對於 Mali 晶片的手機支持度不同,我沒有做深入研究。測試中,我使用了 RGB Compressed PVRTC 4bits 格式來壓縮透明通道貼圖,效果已經完全可以接受了。

如何分離

1. 方案 1

我們很自然而然的會想到,繼承 SpriteRenderer/Image組件去實現運行時替換材質來達到目的。這種方案有一些缺點,對於已經開發到後期的項目來說,要修改所有的組件成本非常高,更不用說在加入版本控制的項目中,修改 prefab 的合併成本也非常高了;另外對於已經使用自定義材質的組件來說也很不方便。

2. 方案 2

直接修改 SpriteRenderData,讓其關聯的texturealphaTexture等信息直接在打包時被正確打入包內。

這樣做的好處就是不需要去修改組件了,只要整個打包流程定製化好以後就能夠一勞永逸了。而對於大多數商業項目來說,定製打包流程基本是必須的,所以這個也就不算是什麼問題了。

實現細節

首先說明一下,本方案在 2017.4 測試通過,其中打圖集是採用已經廢棄的 Sprite Packer 的方式,至於 Sprite Atlas 的方式,我沒有研究過,但我覺得應該都可以實現,只是可能要改變不少流程。

下面說明一下具體實現,在打包之前大致流程如下:

大致解釋一下上面的流程:

  • UpdateAtlases:強制刷新圖集緩存(需要分離 alpha 通道的圖集要修改其壓縮格式為去掉 A 通道的)

  • FindAllEntries:找到所有的 sprite,檢查其 PackingTag,分類整理所有 sprite 和圖集的信息

  • GenerateAlphaTextures/SaveTextureAssets:根據圖集的信息繪製 alpha 通道的紋理並保存文件

  • AssetDatabase.Refresh:實踐中如果不重新刷新的話,可能導致某個貼圖無法找到

  • ReloadTextures:從文件加載紋理,作為寫入 RenderData 的數據

  • WriteSpritesRenderData:最重要的一步,將texturealphaTexture等信息寫入SpriteRenderData

  • 最後,在打包前,禁用 SpritePacker,避免其在打包時重寫打了圖集並覆寫了 SpriteRenderData

其中,關於生成 Alpha 通道貼圖,需要注意的是使用圖集中的散圖位置等信息,將壓縮前的頂點信息直接渲染到貼圖上,這樣透明通道貼圖就不會受到壓縮的影響。


// 臨時渲染貼圖
varrt = RenderTexture.GetTemporary(texWidth, texHeight,0, RenderTextureFormat.ARGB32);
Graphics.SetRenderTarget(rt);GL.Clear(true,true, Color.clear);
GL.PushMatrix;
GL.LoadOrtho;foreach(varspriteEntryinatlasEntry.SpriteEntries){
varsprite = spriteEntry.Sprite;
varuvs = spriteEntry.Uvs;
varatlasUvs = spriteEntry.AtlasUvs;

// 將壓縮前 sprite 的頂點信息渲染到臨時貼圖上
mat.mainTexture = spriteEntry.Texture;
mat.SetPass(0);
GL.Begin(GL.TRIANGLES);
vartriangles = sprite.triangles;
foreach(varindexintriangles)
{
GL.TexCoord(uvs[index]);
GL.Vertex(atlasUvs[index]);
}


GL.End;
}

GL.PopMatrix;

// 最終的 alpha 貼圖
varfinalTex =newTexture2D(texWidth, texHeight, TextureFormat.RGBA32,false);
finalTex.ReadPixels(newRect(0,0, texWidth, texHeight),0,0);

// 修改顏色
varcolors = finalTex.GetPixels32;
varcount = colors.Length;
varnewColors =newColor32[count];
for(vari =0; i < count; ++i){vara = colors[i].a;

newColors[i] = newColor32(a, a, a,255);
}
finalTex.SetPixels32(newColors);

finalTex.Apply;
RenderTexture.ReleaseTemporary(rt);

在將透明通道貼圖寫文件有一點需要注意的是:由於可能打的圖集會產生多個 Page,這些Page的貼圖名都是相同的,如果直接保存可能造成錯誤覆蓋,所以需要使用一個值來區分不同Page,這裡我們使用了 Texture 的 hash code。


// 支持多 page 圖集
varhashCode = atlasEntry.Texture.GetHashCode;

// 導出 alpha 紋理
if(atlasEntry.NeedSeparateAlpha){varfileName = atlasEntry.Name +"_"+ hashCode +"_alpha.png";
varfilePath = Path.Combine(path, fileName);
File.WriteAllBytes(filePath, atlasEntry.AlphaTexture.EncodeToPNG);

atlasEntry.AlphaTextureAssetPath = Path.Combine(assetPath, fileName);
}

接下來再說明一下最重要的寫 SpriteRenderData部分。


varspr = spriteEntry.Sprite;
varso =newSerializedObject(spr);

// 獲取散圖屬性
varrect = so.FindProperty("m_Rect").rectValue;
varpivot = so.FindProperty("m_Pivot").vector2Value;
varpixelsToUnits = so.FindProperty("m_PixelsToUnits").floatValue;
vartightRect = so.FindProperty("m_RD.textureRect").rectValue;
varoriginSettingsRaw = so.FindProperty("m_RD.settingsRaw").intValue;

// 散圖(tight)在散圖(full rect)中的位置和寬高
vartightOffset =newVector2(tightRect.x, tightRect.y);
vartightWidth = tightRect.width;
vartightHeight = tightRect.height;

// 計算散圖(full rect)在圖集中的 rect 和 offset
varfullRectInAtlas = GetTextureFullRectInAtlas(atlasTexture,
spriteEntry.Uvs, spriteEntry.AtlasUvs);
varfullRectOffsetInAtlas =newVector2(fullRectInAtlas.x, fullRectInAtlas.y);

// 計算散圖(tight)在圖集中的 rect
vartightRectInAtlas =newRect(fullRectInAtlas.x + tightOffset.x, fullRectInAtlas.y + tightOffset.y, tightWidth, tightHeight);

// 計算 uvTransform
// x: Pixels To Unit X
// y: 中心點在圖集中的位置 X
// z: Pixels To Unit Y
// w: 中心點在圖集中的位置 Y
varuvTransform =newVector4(pixelsToUnits,
rect.width * pivot.x + fullRectOffsetInAtlas.x,
pixelsToUnits,

rect.height * pivot.y + fullRectOffsetInAtlas.y);

// 計算 settings
// 0 位:packed。1 表示 packed,0 表示不 packed
// 1 位:SpritePackingMode。0 表示 tight,1 表示 rectangle
// 2-5 位:SpritePackingRotation。0 表示不旋轉,1 表示水平翻轉,2 表示豎直翻轉,3 表示 180 度旋轉,4 表示 90 度旋轉
// 6 位:SpriteMeshType。0 表示 full rect,1 表示 tight
// 67 = SpriteMeshType(tight) + SpritePackingMode(rectangle) + packed
varsettingsRaw =67;
// 寫入 RenderData
so.FindProperty("m_RD.texture").objectReferenceValue = atlasTexture;
so.FindProperty("m_RD.alphaTexture").objectReferenceValue = alphaTexture;
so.FindProperty("m_RD.textureRect").rectValue = tightRectInAtlas;
so.FindProperty("m_RD.textureRectOffset").vector2Value = tightOffset;
so.FindProperty("m_RD.atlasRectOffset").vector2Value = fullRectOffsetInAtlas;
so.FindProperty("m_RD.settingsRaw").intValue = settingsRaw;
so.FindProperty("m_RD.uvTransform").vector4Value = uvTransform;

so.ApplyModifiedProperties;

// 備份原數據,用於恢復spriteEntry.OriginTextureRect = tightRect;
spriteEntry.OriginSettingsRaw = originSettingsRaw;

需要修改的部分的含義,這裡面的注釋已經寫的很清楚了,簡單看一下能夠大致理解。其中還有幾個概念需要說明一下:

Sprite的導入設置中,會被要求設置MeshType,默認的是Tight,其效果會基於alpha儘可能多的裁剪像素,而Full Rect則表示會使用和圖片紋理大小一樣的矩形。

這兩個選項在達成圖集時,如果你的散圖周圍的 alpha部分比較多,使用full rect時就會看到圖片分的很開,而使用tight,表現出來的樣子就會很緊湊,效果為下面幾張圖:

上面這個散圖原圖,可以看到周圍透明部分較多

上面這個是使用 Tightmesh type打成的圖集,可以看到中間的間隔較少

上面這個是使用 full rectmesh type打成的圖集,可以看到中間的間隔較大。

一般我們會使用 Tight,那麼我在上面代碼中就需要對tight相關的一些數值做計算,具體如何計算直接看代碼嗎,應該不難理解。

其中還有一個獲取計算散圖(full rect)在圖集中的rect的方法GetTextureFullRectInAtlas,代碼如下:

privatestaticRect GetTextureFullRectInAtlas(Texture2D atlasTexture, Vector2[] uvs, Vector2[] atlasUvs){
vartextureRect =newRect;

// 找到某一個 x/y 都不相等的點
varindex =0;
varcount = uvs.Length;
for(vari =1; i < count; i++)
{
if(Math.Abs(uvs[i].x - uvs[0].x) >1E-06&&
Math.Abs(uvs[i].y - uvs[0].y) >1E-06)
{
index = i;
break;
}
}

// 計算散圖在大圖中的 texture rect
varatlasWidth = atlasTexture.width;
varatlasHeight = atlasTexture.height;
textureRect.width = (atlasUvs[0].x - atlasUvs[index].x) / (uvs[0].x - uvs[index].x) * atlasWidth;
textureRect.height = (atlasUvs[0].y - atlasUvs[index].y) / (uvs[0].y - uvs[index].y) * atlasHeight;
textureRect.x = atlasUvs[0].x * atlasWidth - textureRect.width * uvs[0].x;
textureRect.y = atlasUvs[0].y * atlasHeight - textureRect.height * uvs[0].y;


returntextureRect;
}

最後,需要在自定義打圖集規則,並在判斷需要分離 alpha通道的貼圖,修改其對應壓縮格式,如 RGBA ETC2 改 RGB ETC,RGBA PVRTC 改 RGB PVRTC。這樣做是為了打圖集生成一份不透明貼圖的原圖。大致代碼如下:


// 需要分離 alpha 通道的情況
if(TextureUtility.IsTransparent(settings.format)) {settings.format = TextureUtility.TransparentToNoTransparentFormat(settings.format);
}

至於如何自定義打圖集的規則,可以參考官方文檔:https://docs.unity3d.com/Manual/SpritePacker.html

一些補充

1. 在手機上 UI.Image顯示的貼圖為丟失材質的樣子

原因在於 Image組件使用這套方案時,使用了一個內置的shaderDefaultETC1,需要在Always Included Shaders中去。

2. 分離 alpha通道的貼圖的sprite資源打入包內的形式

通過 AssetStudio 工具看到,下圖是沒有分離 alpha通道的散圖的情況,可以看到每一個Sprite引用了一張Texture2D

下圖是分離了 Alpha通道的圖集的情況,可以看到,這個 AssetBundle 包中只有數個Sprite,以及 2 張Texture2D(非透明貼圖和透明通道貼圖)。

3. 如何知道需要修改 Sprite的哪些Render Data

在實踐嘗試的過程中,通過 UABE 工具來比較不分離 alpha通道和分離alpha通道的兩種情況下Sprite內的Render Data的不同,來確定需要修改哪些數據來達到目的。

從下圖可以看出(左邊是正常圖集的數據,右邊是我嘗試模擬寫入 RenderData的錯誤數據),m_RD中的texturealphaTexturetextureRecttextureRectOffsetsettingsRawuvTransform這些欄位都需要修改。因為我無法接觸到源碼,所以其中一些值的算法則是通過分析猜測驗證得出的。

4. m_RD.settingsRaw的值的意義是什麼

從 AssetStudio 源碼中可以找到 settingsRaw的一部分定義:

  • 0位:packed1表示packed0表示不packed

  • 1位:SpritePackingMode0表示tight1表示rectangle

  • 2-5位:SpritePackingRotation0表示不旋轉,1表示水平翻轉,2表示豎直翻轉,3表示180度旋轉,4表示90度旋轉

  • 6位:SpriteMeshType0表示full rect1表示tight

其中正常生成的圖集的值 67,表示

SpriteMeshType(tight) + SpritePackingMode(rectangle) + packed

5. 在 Unity 2017 測試通過,其他版本可以通過嗎

並不確定。通過查看 AssetStudio 源碼,可以看到序列化後有許多跟 Unity 版本相關的不同處理(下圖),如果在不同版本出現問題,可以通過上面對比打好的 AssetBundle 包的 SpriteRenderData的方式來排查是否需要填寫其他數據。

延伸思考

如果我們把一開始刷新圖集緩存的操作更換成 TexturePacker的話,是否可以使用TexturePacker中的一些特性來為圖集做優化和定製呢?這是可能的,但是這也不是簡單就能做到的東西,還是很繁瑣的,不過的確是一個不錯的思路,有需要的同學可以研究一下。

參考資料

  • IOS 下拆分 Unity 圖集的透明通道(不用 TP):https://zhuanlan.zhihu.com/p/32674470

  • [2018.1] Unity 貼圖壓縮格式設置:https://zhuanlan.zhihu.com/p/113366420

  • (Legacy) Sprite Packer:https://docs.unity3d.com/Manual/SpritePacker.html

文中提到的工具:

  • AssetStudio,一個可以輕鬆查看 AssetBundle 內容的工具:https://github.com/Perfare/AssetStudio

  • UABE,可以解包/打包 AssetBundle,並查看其中詳細數據的工具:https://github.com/DerPopo/UABE

代碼倉庫:

以上的代碼都會整理在代碼倉庫中,該 demo 包含了一個完整的測試實例

https://github.com/RayRiver/UnityAlphaSeparateDemo

關鍵字: