Unity 分离贴图 alpha 通道实践
在做手機游戲時可能會遇到這些問題:
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
直接修改 Sprite 的 RenderData,讓其關聯的 texture,alphaTexture 等信息直接在打包時被正確打入包內。
?
這樣做的好處就是不需要去修改組件了,只要整個打包流程定制化好以后就能夠一勞永逸了。而對于大多數商業項目來說,定制打包流程基本是必須的,所以這個也就不算是什么問題了。
實現細節
首先說明一下,本方案在 2017.4 測試通過,其中打圖集是采用已經廢棄的 Sprite Packer 的方式,至于 Sprite Atlas 的方式,我沒有研究過,但我覺得應該都可以實現,只是可能要改變不少流程。
下面說明一下具體實現,在打包之前大致流程如下:
?
UpdateAtlases(buildTarget);
// 找到所有要處理的項
FindAllEntries(buildTarget, m_spriteEntries, m_atlasEntries);
// 生成 alpha 紋理
GenerateAlphaTextures(m_atlasEntries);
// 保存紋理到文件
SaveTextureAssets(m_atlasEntries);
// 刷新資源
AssetDatabase.Refresh();
// 從文件中加載 alpha 紋理
ReloadTextures(m_atlasEntries);
// 修改所有 sprite 的 Render Data
WriteSpritesRenderData(m_atlasEntries);
// 禁用 SpritePacker 準備打包
EditorSettings.spritePackerMode = SpritePackerMode.Disabled;
大致解釋一下上面的流程:
?
- UpdateAtlases:強制刷新圖集緩存(需要分離 alpha 通道的圖集要修改其壓縮格式為去掉 A 通道的)
- FindAllEntries:找到所有的 sprite,檢查其 PackingTag,分類整理所有 sprite 和圖集的信息
- GenerateAlphaTextures/SaveTextureAssets:根據圖集的信息繪制 alpha 通道的紋理并保存文件
- AssetDatabase.Refresh():實踐中如果不重新刷新的話,可能導致某個貼圖無法找到
- ReloadTextures:從文件加載紋理,作為寫入 RenderData 的數據
- WriteSpritesRenderData:最重要的一步,將 texture,alphaTexture 等信息寫入 Sprite 的 RenderData
最后,在打包前,禁用 SpritePacker,避免其在打包時重寫打了圖集并覆寫了 Sprite 的 RenderData
其中,關于生成 Alpha 通道貼圖,需要注意的是使用圖集中的散圖位置等信息,將壓縮前的頂點信息直接渲染到貼圖上,這樣透明通道貼圖就不會受到壓縮的影響。
?
var rt = RenderTexture.GetTemporary(texWidth, texHeight,
0, RenderTextureFormat.ARGB32);
Graphics.SetRenderTarget(rt);
GL.Clear(true, true, Color.clear);
GL.PushMatrix();
GL.LoadOrtho();
foreach (var spriteEntry in atlasEntry.SpriteEntries)
{
? ? var sprite = spriteEntry.Sprite;
? ? var uvs = spriteEntry.Uvs;
? ? var atlasUvs = spriteEntry.AtlasUvs;
? ? // 將壓縮前 sprite 的頂點信息渲染到臨時貼圖上
? ? mat.mainTexture = spriteEntry.Texture;
? ? mat.SetPass(0);
? ? GL.Begin(GL.TRIANGLES);
? ? var triangles = sprite.triangles;
? ? foreach (var index in triangles)
? ? {
? ?? ???GL.TexCoord(uvs[index]);
? ?? ???GL.Vertex(atlasUvs[index]);
? ? }
? ? GL.End();
}
GL.PopMatrix();
// 最終的 alpha 貼圖
var finalTex = new Texture2D(texWidth, texHeight, TextureFormat.RGBA32, false);
finalTex.ReadPixels(new Rect(0, 0, texWidth, texHeight), 0, 0);
// 修改顏色
var colors = finalTex.GetPixels32();
var count = colors.Length;
var newColors = new Color32[count];
for (var i = 0; i < count; ++i)
{
? ? var a = colors.a;
? ? newColors = new Color32(a, a, a, 255);
}
finalTex.SetPixels32(newColors);
finalTex.Apply();
RenderTexture.ReleaseTemporary(rt);
在將透明通道貼圖寫文件有一點需要注意的是:賣QQ靚號平臺由于可能打的圖集會產生多個 Page,這些 Page 的貼圖名都是相同的,如果直接保存可能造成錯誤覆蓋,所以需要使用一個值來區分不同 Page,這里我們使用了 Texture 的 hash code。
var hashCode = atlasEntry.Texture.GetHashCode();
// 導出 alpha 紋理
if (atlasEntry.NeedSeparateAlpha)
{
? ? var fileName = atlasEntry.Name + "_" + hashCode + "_alpha.png";
? ? var filePath = Path.Combine(path, fileName);
? ? File.WriteAllBytes(filePath, atlasEntry.AlphaTexture.EncodeToPNG());
? ? atlasEntry.AlphaTextureAssetPath = Path.Combine(assetPath, fileName);
}
接下來再說明一下最重要的寫 SpriteRenderData 部分。
?
var so = new SerializedObject(spr);
// 獲取散圖屬性
var rect = so.FindProperty("m_Rect").rectValue;
var pivot = so.FindProperty("m_Pivot").vector2Value;
var pixelsToUnits = so.FindProperty("m_PixelsToUnits").floatValue;
var tightRect = so.FindProperty("m_RD.textureRect").rectValue;
var originSettingsRaw = so.FindProperty("m_RD.settingsRaw").intValue;
// 散圖(tight)在散圖(full rect)中的位置和寬高
var tightOffset = new Vector2(tightRect.x, tightRect.y);
var tightWidth = tightRect.width;
var tightHeight = tightRect.height;
// 計算散圖(full rect)在圖集中的 rect 和 offset
var fullRectInAtlas = GetTextureFullRectInAtlas(atlasTexture,
? ? spriteEntry.Uvs, spriteEntry.AtlasUvs);
var fullRectOffsetInAtlas = new Vector2(fullRectInAtlas.x, fullRectInAtlas.y);
// 計算散圖(tight)在圖集中的 rect
var tightRectInAtlas = new Rect(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
var uvTransform = new Vector4(
? ? 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
var settingsRaw = 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,表現出來的樣子就會很緊湊,效果為下面幾張圖:
?
上面這個散圖原圖,可以看到周圍透明部分較多
?
上面這個是使用 Tight 的 mesh type 打成的圖集,可以看到中間的間隔較少
?
上面這個是使用 full rect 的 mesh type 打成的圖集,可以看到中間的間隔較大。
一般我們會使用 Tight,那么我在上面代碼中就需要對 tight 相關的一些數值做計算,具體如何計算直接看代碼嗎,應該不難理解。
其中還有一個獲取計算散圖(full rect)在圖集中的 rect 的方法 GetTextureFullRectInAtlas,代碼如下:
?
{
? ? var textureRect = new Rect();
? ? // 找到某一個 x/y 都不相等的點
? ? var index = 0;
? ? var count = uvs.Length;
? ? for (var i = 1; i < count; i++)
? ? {
? ?? ???if (Math.Abs(uvs.x - uvs[0].x) > 1E-06 &&
? ?? ?? ?? ?Math.Abs(uvs.y - uvs[0].y) > 1E-06)
? ?? ???{
? ?? ?? ?? ?index = i;
? ?? ?? ?? ?break;
? ?? ???}
? ? }
? ? // 計算散圖在大圖中的 texture rect
? ? var atlasWidth = atlasTexture.width;
? ? var atlasHeight = 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;
? ? return textureRect;
}
最后,需要在自定義打圖集規則,并在判斷需要分離 alpha 通道的貼圖,修改其對應壓縮格式,如 RGBA ETC2 改 RGB ETC,RGBA PVRTC 改 RGB PVRTC。這樣做是為了打圖集生成一份不透明貼圖的原圖。大致代碼如下:
?
if (TextureUtility.IsTransparent(settings.format))
{? ?
? ? settings.format = TextureUtility.TransparentToNoTransparentFormat(settings.format);? ?? ???
}
至于如何自定義打圖集的規則,可以參考官方文檔:https://docs.unity3d.com/Manual/SpritePacker.html
一些補充
1. 在手機上 UI.Image 顯示的貼圖為丟失材質的樣子
原因在于 Image 組件使用這套方案時,使用了一個內置的 shader:DefaultETC1,需要在 Editor -> Project Settings -> Graphics 中將其加入到 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 中的 texture,alphaTexture,textureRect,textureRectOffset,settingsRaw,uvTransform 這些字段都需要修改。因為我無法接觸到源碼,所以其中一些值的算法則是通過分析猜測驗證得出的。
?
4. m_RD.settingsRaw 的值的意義是什么
從 AssetStudio 源碼中可以找到 settingsRaw 的一部分定義:
?
- 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。
?
5. 在 Unity 2017 測試通過,其他版本可以通過嗎
并不確定。通過查看 AssetStudio 源碼,可以看到序列化后有許多跟 Unity 版本相關的不同處理(下圖),如果在不同版本出現問題,可以通過上面對比打好的 AssetBundle 包的 Sprite 的 RenderData 的方式來排查是否需要填寫其他數據。
?
延伸思考
如果我們把一開始刷新圖集緩存的操作更換成 TexturePacker 的話,是否可以使用 TexturePacker 中的一些特性來為圖集做優化和定制呢?這是可能的,但是這也不是簡單就能做到的東西,還是很繁瑣的,不過的確是一個不錯的思路,有需要的同學可以研究一下。
總結
以上是生活随笔為你收集整理的Unity 分离贴图 alpha 通道实践的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 游戏《蔚蓝山》教我的编程道理
- 下一篇: 使用redis实现5万人同服的“相位技术