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);
在將透明通道貼圖寫檔案有一點需要注意的是:由於可能打的圖集會產生多個 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 則表示會使用和圖片紋理大小一樣的矩形。
這兩個選項在達成圖集時,如果你的散圖周圍的 alpha 部分比較多,使用 full rect 時就會看到圖片分的很開,而使用 tight,表現出來的樣子就會很緊湊,效果為下面幾張圖:
上面這個散圖原圖,可以看到周圍透明部分較多
上面這個是使用 Tight 的 mesh type 打成的圖集,可以看到中間的間隔較少
上面這個是使用 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 中去。
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 中的一些特性來為圖集做優化和定製呢?這是可能的,但是這也不是簡單就能做到的東西,還是很繁瑣的,不過的確是一個不錯的思路,有需要的同學可以研究一下。
參考資料
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/
相關文章
- OpenCV(Alpha通道)OpenCV
- 美圖離線ETL實踐
- StoneDB 讀寫分離實踐方案
- 前後端分離,最佳實踐後端
- Mycat讀寫分離配置實踐
- 前後端分離實踐有感後端
- 實踐中的前後端分離後端
- mysql讀寫分離的最佳實踐MySql
- 編碼最佳實踐——介面分離原則
- 前後端分離專案實踐分析後端
- 離線批次資料通道Tunnel的最佳實踐及常見問題
- 前後端分離的思考與實踐(三)後端
- 前後端分離的思考與實踐(四)後端
- 前後端分離的思考與實踐(五)後端
- 前後端分離的思考與實踐(六)後端
- UNITY3D 貼圖格式壓縮說明Unity3D
- 關於Opengl中將24位BMP圖片加入一個alpha通道並實現透明的問題
- 阿里雲在HBase冷熱分離的實踐阿里
- Flink 2.0 狀態存算分離改造實踐
- RGBa顏色 css3的Alpha通道支援CSSS3
- GetPixelAddress()函式Alpha通道會丟失函式
- 一次前後端分離架構的實踐後端架構
- 探討可用於實踐的前後端分離方案後端
- Django+Vue.js搭建前後端分離專案 web前後端分離專案實踐DjangoVue.js後端Web
- 計算與儲存分離實踐—swift訊息系統Swift
- 前後端分離實踐 — 如何解決跨域問題後端跨域
- OSS高速通道跨國複製最佳實踐
- 基於 webpack 的前後端分離開發環境實踐Web後端開發環境
- Unity iOS 使用 ASTC 格式紋理實踐UnityiOSAST
- 圖解基於 Node.js 實現前後端分離圖解Node.js後端
- Shopee ClickHouse 冷熱資料分離儲存架構與實踐架構
- Unity 2018 照明流程最佳實踐Unity
- MySQL主從分離實現MySql
- Nginx實現動靜分離Nginx
- Amoeba實現讀寫分離
- vue專案實踐-前後端分離關於許可權的思路Vue後端
- Docker實踐(5)—資源隔離Docker
- 觀點:實現CQRS分離不如實現一致性分離 - @jroper