Unity 分離貼圖 alpha 通道實踐

rayriver發表於2020-08-14
在做手機遊戲時可能會遇到這些問題:

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

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

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

Unity 分離貼圖 alpha 通道實踐

為什麼要分離

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 通道,將非透明部分和透明部分拆成兩張圖(如下所示)。

Unity 分離貼圖 alpha 通道實踐

Unity 分離貼圖 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 等資訊直接在打包時被正確打入包內。

Unity 分離貼圖 alpha 通道實踐

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

實現細節

首先說明一下,本方案在 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);

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

// 支援多 page 圖集
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 spr = spriteEntry.Sprite;
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 則表示會使用和圖片紋理大小一樣的矩形。

Unity 分離貼圖 alpha 通道實踐

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

Unity 分離貼圖 alpha 通道實踐

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

Unity 分離貼圖 alpha 通道實踐

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

Unity 分離貼圖 alpha 通道實踐

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

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

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

private static Rect GetTextureFullRectInAtlas(Texture2D atlasTexture, Vector2[] uvs, Vector2[] atlasUvs)
{
    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。這樣做是為了打圖集生成一份不透明貼圖的原圖。大致程式碼如下:

// 需要分離 alpha 通道的情況
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 中去。

Unity 分離貼圖 alpha 通道實踐

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

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

Unity 分離貼圖 alpha 通道實踐

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

Unity 分離貼圖 alpha 通道實踐

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

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

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

Unity 分離貼圖 alpha 通道實踐

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。

Unity 分離貼圖 alpha 通道實踐

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

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

Unity 分離貼圖 alpha 通道實踐

延伸思考

如果我們把一開始重新整理圖集快取的操作更換成 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


來源:indienova
原文:https://indienova.com/indie-game-development/unity-alpha-separate/

相關文章