您現在的位置是:首頁 > 攝影首頁攝影
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
:強制重新整理圖集快取(需要分離 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[i]。a;
newColors[i] =
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[i]。x - uvs[
0
]。x) >
1E-06
&&
Math。Abs(uvs[i]。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
,需要在
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