2017年3月17日 星期五

Unity推廣活動 - 加速開發包


活動日期:2017/3/13 起至 2017/5/28 止。

內容:在活動期間訂 Unity Plus 或 Unity Pro至少一年,每一套即可免費獲得一套價值 190 美元的“加速開發包”,裡面包括Asset Store裡非常熱門的三個套件:


Playmaker




Amplify Shader Editor




Ultimate FPS




注意事項:

 以原價或正常經銷/代理價格購買授權才享有此活動優惠。如不符合此資格請洽原廠以個案處理。

 一套對應一個兌換券碼,兩套即可有兩個兌換券碼,依此類推。

 客戶拿到
兌換券碼之後需於 30 天之內自行到資源商店下載 套件使用,過期失效。

 活動期間下訂單時,請註明是否需要
兌換券碼,如註明需要,會在出貨時一併附上。


FAQ

Q: 如果我購買後,資源發行商更新了版本並要求我為更新付費怎麼辦?
A: 如果資源發行商更新了資源,並且您想要獲得最新版本,則請到 Asset Store上付費購買。

Q: 可以將加速開發包給朋友或同事使用嗎?
A: 在未使用兌換卷之前,可將其贈予朋友或同事。一旦使用成功,就不能再次使用,也不能轉贈給其他人。

Q: 可以退掉加速開發包裡的某幾個不喜歡的套件嗎?
A: 很抱歉,無法辦理退貨。如果對某個套件有使用疑問,請直接聯繫該發行商。

Q: 我已擁有其中幾個套件,是否可以換成其他套件?
A: 很抱歉,本次活動無法替換或自由組合。

Q: 我在兌換加速開發包或下載套件的時候遇到了問題
A: 如果您在兌換加速開發包或下載套件的時候遇到問題,請發送郵件(英文)至:mikeg@unity3d.com 尋求協助。

Q: 我會受到 Unity Plus 或 Unity Pro 使用者條款的規範嗎?
A: 是的,您會受限於標準使用者條款的規範,並且在合約期截止前不能取消訂閱。

Q: 這個資源商店 Asset Store 的兌換券代碼是否會過期?
A: 會的,您需於 30 天內使用並下載套件。

Q: 我的訂單包括多少份加速開發包?每個加速包裡有幾個套件?
A: 一套 Unity Pro 或 Unity Plus 對應一個加速開發包,每個加速包裡含下列三個套件:


一個 Playmaker
一個 Amplify Shader Editor
一個 Ultimate FPS

2017年3月14日 星期二

如何在Unity裡製作卡通渲染效果

作者:Marek Marchlewicz 參考原文
潤稿 - 阿祥 - 阿祥的開發日常

Twin Souls: Path of the Shadows是一款遊戲性相當完善,且卡通渲染效果相當優異的手機遊戲,本文由 Unity 大中華區技術經理Marek Marchlewicz(馬瑞),為大家分享一篇如何透過修改 Unity 的渲染管道來實現與Twin Souls: Path of the Shadows一樣的卡通渲染效果。



卡通渲染原理


卡通渲染是一種非真實感的渲染方法,一般也稱作 Cel-Shading 或 Toon Shading,透過將平滑陰影變化轉換成擁有明顯邊界,讓畫面呈現出手繪般的效果。


上圖中,由左至右分別是 Diffuse、Cel Shading 以及擁有三個臨界值的 Cel Shading。在通常情況下一個像素所接收到的光源強度是光源方向與法線方向之間的點積(NdotL)。將這個光源強度經由四捨五入調整,或是透過不同“臨界值”的取樣後,就會呈現出不同的卡通渲染效果。在最簡單的卡通渲染效果中,如果點積大於零,則像素會呈現高光,反之如果點積小於零,則像素會被設定成陰影。

實現方法


卡通渲染效果有許多不同的實現方式,而這次我們會針對渲染路徑(Renderring Path)來完成不同的實現方法。渲染路徑可以在攝影機的 Inspector 面板中設定。下面我們會介紹如何透過正向渲染與延遲渲染路徑來實現卡通渲染效果。


正向渲染(Forward Rendering)


如果你的場景並不是多光源場景,正向渲染(Forward Rendering)是一個不錯的選擇。因為在這種模式下,渲染引擎會遍歷所有光源的頂點及像素,在多光源場景裡可能會帶來較大的負荷。但是,若是你的場景中只有少數或甚至一個光源,這種遍歷方式就不會造成負擔。具體實現時,可以創建一個表面著色器(Surface Shader),並透過自定義的光照模型,來實現如何在正向渲染中完成卡通渲染效果。



Shader "Custom/CelShadingForward"
{
Properties
{
_Color("Color", Color) = (1, 1, 1, 1)
_MainTex("Albedo (RGB)", 2D) = "white" {}
}


SubShader
{
Tags
{
"RenderType" = "Opaque"
}
LOD 200


CGPROGRAM
#pragma surface surf CelShadingForward
#pragma target 3.0


half4 LightingCelShadingForward(SurfaceOutput s, half3 lightDir, half atten)
{
half NdotL = dot(s.Normal, lightDir);


if (NdotL <= 0.0)
NdotL = 0;
else
NdotL = 1;


half4 c;
c.rgb = s.Albedo * _LightColor0.rgb * (NdotL * atten * 2);
c.a = s.Alpha;


return c;
}


sampler2D _MainTex;
fixed4 _Color;


struct Input
{
float2 uv_MainTex;
};


void surf(Input IN, inout SurfaceOutput o)
{
// Albedo comes from a texture tinted by color
fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
o.Albedo = c.rgb;
o.Alpha = c.a;
}
ENDCG
}
FallBack "Diffuse"
}

經由 “LightingCelShadingForward” 的運算,可以計算出 NdotL,並且透過 NdotL 來完成卡通渲染效果。此外,還可以使用簡單的方法來避免 “if else” 的使用。可行的方法為:
NdotL = 1 + clamp(floor(NdotL), -1, 0);


若是需要模糊邊緣則可以使用:


NdotL = smoothstep(0, 0.025f, NdotL);

延遲渲染(Deferred Rendering)


與正向渲染相比較下,延遲渲染(Deferred Rendering)的優點是在多光源的場景中可以得到較優異的性能表現。當使用延遲渲染時,引擎會先遍歷場景中的每個光源,並且將光源與場景的幾何信息儲存在緩衝區中。

此外為了重複利用 Unity 的新功能以及強大的 Standard Shader,我們這次使用的方法運用了新的 Unity 延遲渲染運作流程。而這種做法從最初的解決方案發布以來,Unity 對此又進行了許多更新,所以需要完成下列步驟:


  • 從 Unity 官網下載 Built-In Shader。
  • 將下載文件進行解壓縮,在 “DefaultResourcesExtra” 資料夾中找到 “Internal-DeferredShading.shader”,並複製到專案中的 “Resources” 資料夾
  • 在解壓縮文件中的 “CGIncludes” 資料夾裡找到 “UnityDeferredLibrary.cginc” 以及 “UnityStandardBRDF.cginc”,並將這些檔案複製到轉案中的 “Recourses” 資料夾
  • 將 “UnityDeferredLibrary.cginc” 重新命名為 “UnityDeprecatedEx.cginc”,並新增下列方法:

inline half CustomDotClamped ( half3 a, half3 b )

{

#if (SHADER_TARGET < 30)

return saturate(dot(a,b));

#else

return max(0.0h, dot(a,b));

#endif

}
inline half CustomLambertTerm ( half3 normal, half3 lightDir )

{

return smoothstep(0.0,0.05f, CustomDotClamped (normal, lightDir));

}


  • 將 “UnityStandardBRDF.cginc” 重新命名為 “UnityStandardBRDFCustom.cginc”,複製 BRDF1_Unity_PBS 方法,重命名為 BRDF_CUSTOM_Unity_PBS 方法,並將下列代碼取代:
half nl = saturate(dot(normal, light.dir));
取代為:

half nl = CustomLambertTerm(normal, light.dir);


  • 在 “Internal-DeferredShading.shader” 中,修改匯入的 CG 庫名稱 :


#include "UnityDeferredLibrary.cginc"

#include "UnityStandardBRDF.cginc"

修改為:

#include "UnityDeprecatedEx.cginc"

#include "UnityStandardBRDFCustom.cginc"


  • 在 “Internal-DeferredShading.shader” 中,刪除 UnityPBSLighting.cginc。
  • 將 “Internal-DeferredShading.shader” 的 UNITY_BRDF_PBS 方法修改成 UNITY_CUSTOM_BRDF_PBS 方法


half4 res = BRDF_Unity_PBS (......);


修改成:


half4 res = BRDF_CUSTOM_Unity_PBS (......);

在完成上述的所有步驟後,我們的 “Internal-DefferredShading.shader” 就大功告成了!接著我們就可以在 Edit > ProjectSettings > Graphhics > Defferred 中選擇我們修改後的 “Internal-DeferredShading.shader” 著色器。


最後,確保攝影機的渲染路徑切換成正確的延遲渲染後,可以注意到我們在標準著色器(Standard Shader)的基礎上完成了卡通渲染效果,如下圖所示。



外輪廓(Toon outline)


許多卡通風格的遊戲中,都會對物體進行外輪廓的繪製,這種方式會讓畫面呈現出漫畫一般的視覺效果,而這種外輪廓效果當然也有許多不同的實現方式。可以透過在著色器中檢查法線方向與視角方向的點積,或是使用 Two Pass 來做兩次渲染,當然也可以在後製效果中實現。最簡單的應用方案是在攝影機中添加 Edge Detection 效果:


  • 點擊 Assets > Import Package > Effects,導入 Standard Assets Effect
  • 選擇場景中的攝影機,並且在檢視面板(Inspector)中點擊 Add Component > Image Effect > Edge Detection > Edge Detection

最後完成的效果如下。



最終效果


在本文中,我們參考了《Twin Souls: The Path of Shadows》中的卡通渲染效果,最後在幾乎沒有使用其他添加資源的前提下,透過修改 Unity 的渲染管道來加以實現卡通風格渲染,完成了類似的畫面呈現,如下圖所示。



總結


這種透過使用延遲渲染所完成的卡通渲染效果技術相當有用,因為它並不會替換整個光照模型,而只是針對現有模型做特殊的陰影處理,可以在幾乎沒有額外性能開銷的情況下保留 Unity的 PBR(Physically-Based Rendering)技術並完成卡通渲染效果。

2017年3月6日 星期一

最佳實踐 - 了解Unity效能 - 目錄

作者:Ian 原文

翻譯:Kelvin Lo / 海龜


本篇文章主要是 Unite Europe 2016 Ian 和 Mark 一次關於手遊優化後的演講討論的延伸。 想要看當時的優化分享可以從這裡去看

雖然在那次的分享主要是針對手遊與一般開發者,但本文將會儘量涵蓋其他平台為主。 

雖然 Unite 那場演講標題可能會讓人覺得內容只跟手機開發有關,但實際上大部分的內容對一般的 Unity 開發者都有用。而這份補充文件會盡量討論跟平台無關的通用 Unity 效能相關資訊。

Unity最佳化 - 目錄

  1. 分析
  2. 記憶體
  3. 協同
  4. 資源審查
  5. GC和Managed Heap
  6. 字串和Text
  7. Resources目錄和一般最佳化
  8. 特別最佳化

最佳實踐 - 了解Unity效能 - 分析

作者:Ian 原文

翻譯:Kelvin Lo / 海龜

Profiling - 分析 

說到優化,不得不說所有優化的源頭都是從發現問題開始,第一步是分析,根據專案技術和資源結構的分析報告結果來劃出專案問題的可能範圍。
注意:本文裡使用的一些追蹤程式碼是基於Unity 5.3版本,在未來這些功能有可能會變動。
注意:這個章節討論的原生(Native)方法(Method)名稱是由 Unity 5.3 的執行檔擷取出來的,這些方法名稱可能在未來的版本有所改變。

Tools - 工具 

關於分析,有許多能幫助 Unity 開發者分析專案的工具,而 Unity 本身有一套內建工具像是 CPU Profiler、Memory Profiler、或是 5.3 才有的 Memory Analyzer。

但是最好的分析報告通常來自於該平台的特有工具,像是:
 
  • iOS:Instruments 和 Xcode Frame Debugger 
  • Android:Snapdragon Profiler 
  • 採用 Intel CPU/GPU的平台:VTune 和 Intel GPA 
  • PS4:Razor suite 
  • Xbox:Pix tool 
這些工具大部分都能夠分析IL2CPP所包出來的C++專案,這些原生程式能執行原本在Mono下無法執行的高精度計時(high-resolution method timings)和更透明的堆疊呼叫(callstacks)。

要充分利用這些工具通常需要在這些平台上啟用 IL2CPP 然後使用轉換成 C++ 版本的專案。在原生程式碼狀態下可以透過原生工具得到完整的呼叫堆疊還有高精度計時,這些在透過 Mono 執行的時候都無法取得。
Unity 已經發表過一份關於在 iOS 平台使用 Instruments 分析的教學指南,點這裡


解析啟動軌跡 

當查看啟動時間的軌跡時,有兩個關鍵的方法來檢視,分別是從專案設定、資源和程式可能影響啟動時間的可能範圍。
請注意:啟動時間在不同的平台上會有差距,大多都是用啟動到顯示 Unity Logo(splash screen)的時間來判定。
注意 Unity 的啟動時間在不同平台上可能表現會不同,大部分的平台的行為是會讓使用者會在靜態的 Unity Logo(Splash screen)等待得時間變長。



上圖為 iOS 上用 Instruments 抓的啟動時間追蹤,可以看到 iOS 平台特有的 startUnity 方法呼叫了 UnityInitApplicationGraphics 和 UnityLoadApplication 方法。

UnityInitApplicationGraphics 執行很多內部工作,像是圖形裝置的設定工作和初始化很多Unity的內部系統。不但如此,他還負責初始化 Unity 的資源系統(Resources system)。所以它需要先載入資源系統所含的所有檔案索引。

所有目錄名為 Resources 裡面的資源檔案(在 Assets 目錄下的 Resources 目錄,還有 Resources 目錄下的目錄)都會被算在資源系統裡。因此初始化資源系統所需要的時間將會隨著 Resources 目錄裡的檔案數量增加而變久。

UnityLoadApplication 的工作包含載入和初始化專案最開始的場景。這包含反序列化(deserializing)和實例化(Instantiating)展示第一個場景需要的資料,例如編譯著色器(compiling Shaders)、上傳貼圖和實例化遊戲物件(GameObject)等等所有為了顯示初始場景所需要的資料。此外,第一個場景中所有的 MonoBehaviours 都會在這個時間點執行 Awake 回呼(Callback)。

這樣的呼叫結構代表如果你在專案的第一個場景裡的某個 Awake 回呼執行了非常耗時的程式,會拖慢整個專案的啟動時間。要解決這樣問題當然是要修改拖慢的程式碼或是移到別的地方來執行。

解析執行軌跡 啟動初始化之後主要是 PlayerLoop 的追蹤。這是 Unity 主要週期循環,裡面的程式每幀會執行一次(註一)


上圖是一個 Unity 5.4 專案的分析報告,顯示了幾個 PlayerLoop 會呼叫的最有趣的方法。請注意,PlayerLoop 裡方法的名稱可能會因不同版本的 Unity 而有所不同。

PlayerRender 是執行 Unity 渲染系統的方法。也負責剔除不顯示的物件、計算動態批次計算(Dynamic batches)和對 GPU 送出繪圖指令。所有的影像後製(Image Effects)或是基於渲染的程式回呼(例如 OnWillRenderObject)也都在此處理。一般來說,當專案跟玩家互動時 CPU 大部分時間都會花在這裡。

BaseBehaviourManager 會呼叫三種不同版本的 CommonUpdate,它們各自會呼叫場景裡被啟動(Active)的 GameObject 上的 MonoBehaviours 的特定回呼。

  • CommonUpdate<UpdateManager> 會呼叫 Update 回呼。 
  • CommonUpdate<LateUpdateManager>會呼叫 LateUpdate 回呼。 
  • CommonUpdate<FixedUpdateManager> 會呼叫 FixedUpdate(如果物理系統被觸發) 
一般來說 BaseBehaviourManager::CommonUpdate<UpdateManager> 是最有意思的方法,因為它在 Unity 專案裡大多數腳本的進入點。

還有幾個有趣的方法:

如果專案有用到 Unity UI 系統,UI::CanvasManager 會呼叫幾個不同的回呼。這行為包含了Unity UI 的批次運算和排版更新,這兩個操作最常造成 CanvasManager 出現在分析報告裡。

DelayedCallManager::Update 執行共常式(Coroutines)。在底下的 Coroutines 章節會有更詳細的說明。

PhysicsManager::FixedUpdate 執行 PhysX 物理系統。涉及到執行 PhysX 的內部程式,並受到場景內有物理行為物件的數量影響(像是帶有 Rigidbody 和各種 Collider 的物件)。然而,跟物理有關的回呼也會出現在此,特別是 OnTriggerStay 和 OnCollisionStay。

假如專案用的是2D物理(Box2D),類似的結構會出現在 Physics2DManager::FixedUpdate 之下。 



解析腳本方法

當使用 IL2CPP 跨平台編譯時,可以找看看 ScriptingInvocation 這行包含的內容,這是從 Unity 內部原生程式進入到使用者腳本的分界點。(註二) 



上圖是一個 Unity 5.4 專案的另一個分析報告,附掛在 RuntimeInvoker_Void 底下的所有方法都是交叉編譯過的 C# 腳本的一部分,每幀執行一次。

這些追蹤還蠻容易理解的,每一個命名規則都是「原始類別_原始方法」,例如上圖範例範例能找到 EventSystem.Update、PlayerShooting.Update 以及其他幾個 Update 方法。這些都是在MonoBehaviours 裡能找到的標準 Unity Update 回呼。

透過展開這些方法,可以更精確的定位誰在消耗 CPU 時間。這包含了專案的腳本方法、Unity API 和 C# 函式庫。

上面顯示出 StandaloneInputModule.Process 方法每幀會對整個 UI 進行一次 Raycasting,檢查是否有任何 UI 事件被點擊或滑過的動作觸發。主要消耗是在逐一檢查所有 UI 元素,並測試滑鼠的位置是否在其範圍內。(註三)

資源載入



資源載入也能從 CPU 追蹤裡找到,載入資源的主要方法是 SerializedFile::ReadObject,它透過名為“Transfer”的方法將檔案透過 2 進制(binary)的資料串流連接到 Unity 的序列化系統(Serialization system)。Transfer 方法可以在所有資源的載入過程中看到,例如材質、MonoBehaviours 和粒子系統。

上圖顯示出一個場景正被載入,這會讀取並反序列化(Deserialize)場景中所有資源,可以看到SerializedFile::ReadObject 之下有各種不同 Transfer 呼叫。

一般來說,如果在執行時遇到了效能問題,並追蹤看到 SerializedFile::ReadObject 用掉大量的時間,代表FPS會下降是因為資源正在載入。要注意的是絕大部分的情況下,只有當透過 SceneManager、Resources 或 AssetBundle API 請求同步(Synchronous)載入時,才會在主執行緒上看到 SerializedFile::ReadObject。

這種效能問題通常能簡單解決:可以採用非同步方式(Asynchronous)載入資源(將比較吃重的 ReadObject 呼叫放到工作執行緒(Worker thread)上),或預先載入那些較大的資源。

請注意,當複製(Cloning)物件時也會產生 Transfer 呼叫(在追蹤表的CloneObject方法裡)。假如一個Transfer 呼叫出現在 CloneObject 下就代表它不是從硬碟載入而是從一個舊的物件資料轉移到一個新的物件。Unity 做法是對舊的物件進行序列化之後將產生的資料反序列化成為新的物件。


註一:這只適用於在”Assets”底下的”Resources”目錄以及底下所有名為”Resources”的子目錄。
註二:技術上來說,執行IL2CPP之後,C#/JS腳本也會轉為原生程式(Native code)。然而,這種交叉編譯程式主要是透過IL2CPP的框架來執行,不會像手動寫程式那麼嚴謹。
註三:在Unity 5.4之前,StandaloneInputModule在沒有滑鼠的裝置上查詢滑鼠輸入存在著一些缺陷,還好後面的版本已經修復,大大降低了StandaloneInputModuleCPU消耗

Unity最佳化 - 目錄


  1. 分析
  2. 記憶體
  3. 協同
  4. 資源審查
  5. GC和Managed Heap
  6. 字串和Text
  7. Resources目錄和一般最佳化
  8. 特別最佳化

最佳實踐 - 了解Unity效能 - 協同

作者:Ian 原文

翻譯:Kelvin Lo / 海龜


Coroutines - 協同

Coroutine 運作方式和其他腳本程式不同,大多數的程式消耗只會出現在追蹤報告裡的單一位置,在特定的回呼被執行,但是 Coroutine會出現在 CPU 報告的兩個不同地方。

所有在 Coroutine 裡的初始化程式,從方法開始到第一次的 yield,這部分的追蹤數據會出現在 Coroutine 啟動的地方,通常是 StartCoroutine 被呼叫的地方。直接從從 Unity 回呼啟動的 Coroutine(比如回傳值宣告成 IEnumerator 的 Start)則會顯示在各自的 Unity 回呼下。

所有剩下的 Coroutine程式,從第一次被繼續執行到結束,都會出現在 Unity 主迴圈下的DelayedCallManager 裡。

要知道為何會這樣,就必須了解 Coroutine 的運作方式。

Coroutine 底下有個 C# 編譯器自動產生的類別的實例, 對程式設計師來說看起來像是一個普通的方法,但在實作上我們需要這個物件在每次呼叫之間保存這個 Coroutine 的狀態。因為 Coroutine 中的區域變數(local-scope variables)在 yield 之間必須保有之前賦予的值,所以這些區域變數會提到先前所說的 C# 編譯器產生的類別,它的實例在 Coroutine 的執行結束之前會維持配置在堆積記憶體上(Heap memory)。這個物件也負責追蹤 Coroutine 的執行狀態:它會記住 Coroutine 在 yield 之後下一次該從哪邊繼續。

因此,啟動一個 Coroutine 所引起的記憶體消耗同等於一個固定的成本加上這個 Coroutine 用到的區域變數總合的大小。

啟動 Coroutine 的程式建構並呼叫這個自動生成物件上的方法,然後 Unity 的 DelayedCallManager 在 Coroutine的 yield 時給的條件(WaitForSeconds、WaitForFixedUpdate 之類的條件)滿足時再次呼叫它。由於 Coroutine通常在其他 Coroutine 之外啟動 ,這會讓它們的執行消耗拆分成上述兩個不同位置。 



上圖可以看到 DelayedCallManager 正重新呼叫幾個不同的 Coroutine,像是 PopulateCharacters、AsyncLoad 和 LoadDatabase。

如果可以,儘可能用最少 Coroutine 做最多的事,雖然巢狀 Coroutine(從 Coroutine 再產生 Coroutine)非常好維護也能維持程式簡潔,但每次使用 Coroutine 就要配置新的追蹤用物件,用越多Coroutine 代表記憶體消耗也越多。

如果 Coroutine 幾乎每一幀都要執行,也沒有用 yield 在需要長時間等待的操作上,改回用 Update 或LateUpdate 會比較好。尤其是對那種周期很長或是無窮的 Coroutine 更是如此。

要記得很重要的一點是 Coroutine「不是」執行緒(threads),Coroutine 裡的同步(Synchronous)行為仍然是在主執行緒上執行的,因此如果你的目的是降低主執行緒在 CPU 上的消耗,就跟呼叫一般方法一樣必須避免在 Coroutine 上面執行會卡住(Blocking)執行緒的操作。

Coroutine 還是最適合處理長時間的非同步操作,比如等候 HTTP 傳輸、資源載入或是檔案 I/O。


Unity最佳化 - 目錄


  1. 分析
  2. 記憶體
  3. 協同
  4. 資源審查
  5. GC和Managed Heap
  6. 字串和Text
  7. Resources目錄和一般最佳化
  8. 特別最佳化

最佳實踐 - 了解Unity效能 - 記憶體

作者:Ian 原文

翻譯:Kelvin Lo / 海龜

Memory - 記憶體

記憶體的消耗是關鍵性的指標,尤其是在像低階手機這種記憶體有限的平台。

分析記憶體耗用

在 Unity 檢查記憶體的問題最好用 Unity 放在 Bitbucket 上開源的記憶體檢查工具。整合這個工具到 Unity 很簡單,只要下載解開之後把裡面的Editor目錄拉進專案即可。

這套工具適用於 Unity 5.3 之後到目前的版本(Unity 5.6),當它連到 IL2CPP 建置的專案時可以捕捉到原生跟非原生程式記憶體耗用的資訊。

裝好工具後確定專案設定是用 IL2CPP 編譯,把專案放到設備上執行後和 Unity 編輯器的 Profiler 視窗連接,然後打開 Memory Profiler 視窗(套件有裝好的話從 Window > MemoryProfilerWindow 開啟),並按一下 Take snapshot。

在數據收集並傳回 Unity 時,設備上的應用程式會暫停一段時間。然後 Unity 編輯器會開始解析接收的數據,這會需要一些時間。對於用了大量記憶體的專案有可能會花上 10-30 分鐘。

解析過程中請耐心等候


上圖是用 iOS 裝置執行 Standard Assets 裡的場景,顯示超過 ¾ 的記憶體用量來自於四張很大的貼圖,查出是飛機的機身相關貼圖。

這個圖表每一項都可以縮放,點進去能看更詳細的資訊。

找出重複的貼圖

一個比較常見的記憶體問題是資源重複載入,貼圖通常是占用記憶體最密集的資源,貼圖重複也是在 Unity 專案裡最常發生的問題之一。

要找出是否有貼圖重複載入可以透過比對記憶體裡是否有兩個相同類型和大小的物件並檢查是否是從同一個來源載入的,在新版的 Memory Profiler 的介面裡,可以比對看起來相同物件的 Name 和InstanceID 字串。

Name 欄位表示的是載入物件的素材檔案名稱,一般來說就是檔名(不含資料夾路徑跟副檔名)。InstanceID 表示的是由 Uinty 執行時分配的內部編號,這個編號會是每次執行產生的唯一編號。(註一) 




上圖是一個簡單的案例表示,圖的左右兩側是在 Unity 5.4 裡用 Memory Profiler 所抓取的資料,圖中顯示的資源分別是在記憶體載入的兩張貼圖。兩張貼圖具有相同名稱和大小,代表它們可能是重複的。檢查了 Assets 目錄和其子目錄之後,只找到一個名為 wood-floorboards-texture 的圖檔,因此這非常有可能就是重複載入資源的問題。

記憶體裡每個單獨的 UnityEngine.Object 都會在建立時分配一個唯一的 InstanceID,由於這兩張貼圖的 InstanceID 並不同,所以可以確定它們是兩份不同的載入記憶體的貼圖資料。

由於檔名和大小相同 InstanceID 卻不同,因此可以判定這是重複載入的貼圖。(註二)



AssetBundles 和資源重複載入的關係

一般來說造成貼圖和資源重複載入記憶體最有可能的元兇是不正確的 AssetBundle 卸載,關於這部分可以參考官網的 AssetBundles 最佳實踐系列文章,關鍵在於管理載入的資源這個章節。 


檢查 Image Buffer、Image Effect 和渲染貼圖記憶體的使用情況

用 Memory Visualizer 你還能看到從渲染暫存區(Render buffers )提供給 Image effect 和 RenderTexture 物件的記憶體使用情況。

上圖是一個單純帶有一些 Image Effects 的場景所擷取的資料,Image Effects 為了要計算在記憶體配置了暫用的渲染暫存區,比較特別的是 Bloom 效果配置了一系列的暫存區。由於 Retina iOS 設備解析度很高,造成這些臨時暫存區消耗的記憶體比專案其他部分加總還多。

設想 iPad Air 2 的原生解析度是 2048x1536,這已經超出新一代的遊戲機和 PC 標準的 1080p,但卻要在平板上執行。取決於暫存區的位元深度不同,一個全螢幕的 Render buffer 可能會用掉整整 24mb甚至 36mb 的記憶體空間。想要降低這個值可以把 Render buffer 的解析度減半,可以降低約 75% 的記憶體耗用,通常視覺上來看差別也不會太大。

要改善 Image Effects 占用太多 Render buffer 和 GPU 資源的方法,可以採用 Unity 5.5 或以上版本支援的 “uber” Image Effect,官方提供一個名為 UberFX 的套件可以把許多不同層的 Image Effect 疊加在一起成單一一個效果後才送去運算, 能減少更多的開銷。

註一:請注意,編號是執行週期產生分配,同專案不同執行週期編號也不會相同。
註二:注意:如果在專案中有相同名字的貼圖,那這裡就要小心是否判斷錯誤,但相同名稱又相同大小的機率不高,仍可作為依據。


Unity最佳化 - 目錄


  1. 分析
  2. 記憶體
  3. 協同
  4. 資源審查
  5. GC和Managed Heap
  6. 字串和Text
  7. Resources目錄和一般最佳化
  8. 特別最佳化

最佳實踐 - 了解Unity效能 - 資源審查

作者:Ian 原文

翻譯:Kelvin Lo / 海龜



Asset auditing - 資源審查

許多專案發生效能問題的真正原因只是由於人員操作不當或是試東試西,而不小心改到匯入設定影響到匯入的資源。(例如最近的gitlab慘案)

對於較大規模的專案,最好準備一道自動的防線防範人為失誤。例如寫一段簡單的檢查程式確保沒有任何人能在專案加入一張沒壓縮的 4K 貼圖。

或許你會覺得不可能,但這問題我們真的很常見。沒有壓縮的 4K 貼圖會占用大約 60mb 的記憶體空間,在低端的手機設備(例如 iPhone 4s)上,整個專案用掉超過 180mb~200mb 就會很危險。有時候你的遊戲在好的手機上跑沒問題,在差的手機上跑會當機,不一定是硬體的問題。如果犯這種錯誤,這張貼圖會無端占用應用程式四分之一到三分之一的可用記憶體,造成很難追蹤的記憶體不足錯誤。

雖然上面有提到可以用 Unity 5.3 的 Memory Profiler 來追蹤這樣的問題,但最好養成良好開發習慣排除這樣的可能。 


使用資源後處裡器(Asset Postprocessors)


Unity 編輯器裡的 AssetPostprocessor 類別可以用在 Unity 專案上實行某些基本限制。這個類別在資源匯入時會收到一個回呼。使用方法即繼承 AssetPostprocessor 並實作一個或多個 OnPreprocess 方法,重要的包含:

  • OnPreprocessTexture 
  • OnPreprocessModel 
  • OnPreprocessAnimation 
  • OnPreprocessAudio 

請查詢 AssetPostprocessor API 文件以了解更多關於 OnPreprocess 方法的資料。

public class ReadOnlyModelPostprocessor : AssetPostprocessor { public void OnPreprocessModel() { ModelImporter modelImporter = (ModelImporter)assetImporter; if(modelImporter.isReadable) { modelImporter.isReadable = false; modelImporter.SaveAndReimport(); } } }

這是一個簡單的 AssetPostprocessor 限制規則範例

每當匯入模型到專案或模型的匯入設定(Import settings)被修改時會呼叫這個類別,這裡程式只是檢查可否讀寫模型的旗標 isReadable 屬性,如果是 true 就會改為 false,存檔後重新匯入資源。

請注意,呼叫 SaveAndReimport 會導致這段程式會被再次呼叫!但由於旗標已經被改為 false,所以不會無窮遞迴下去。

至於為何要拿掉寫入的旗標會在底下的模型章節解釋。

常見資源規則

Textures - 材質

關閉 Read/Write Enabled 旗標

這個 Read/Write Enabled 的旗標會造成貼圖在記憶體裡變成兩份,一份在 GPU 上一份在 CPU 可以定址的記憶體上。這是因為大多數平台,把資料從 GPU 記憶體讀回 CPU 很慢。從 GPU 記憶體讀取一張貼圖到暫存區給 CPU 程式用(例如:Texture.GetPixel)會導致效能很差。這個設定在 Unity 裡預設是關閉的,但要避免誤勾這個選項。

Read/Write Enabled 只有在 Shader 以外的地方存取貼圖資料(例如:Texture.GetPixel 和Texture.SetPixel 這樣的 API)時才會需要,但儘量避免使用這個功能比較好。

如果可以,儘量不要用 Mipmaps 

對於物件相對於鏡頭之間的 Z 軸深度不太會有變化的物件,關閉 Mipmaps 可以省下約三分之一的記憶體空間,但如果物件在鏡頭看出去的深度會有變化,關閉 Mipmaps 會影響到 GPU 貼圖取樣的效率。

一般來說,關閉這個選項對於 UI 和不太會變大變小的貼圖很有幫助。

壓縮全部貼圖

針對各個不同的平台選用該平台適合的壓縮格式壓縮貼圖,這是節省記憶體的首要之務。

如果選到目標平台不支援的格式,Unity 會在 CPU 上解壓縮貼圖,消耗 CPU 時間和很多記憶體。這類問題比較常發生在 Android 上,開發者得注意 Android 裝置上各種不同的晶片各支援哪種壓縮格式。

強制貼圖大小

這雖然是件小事但很多人會忘記調整貼圖大小或不小心從改到匯入設定裡的貼圖大小上限。先決定各種貼圖的合理大小上限然後用程式去強制限制它。

大多數的手機遊戲用 2048x2048 或 1024x1024 來存放圖集(Atlas)就夠了,3D 手機遊戲的模型貼圖用到 512x512 應該就夠好了。

Models - 模型

關閉 Read/Write Enabled 旗標

這個選項的運作原理和貼圖一樣,只不過預設是打開的。

當專案執行時想用程式來修改 Mesh,或者如果 Mesh 要用作 MeshCollider 的話,這裡需要打勾。反之如果模型沒用在 MeshCollider,也沒用程式來修改 Mesh 的話,關閉這裡可以省下一半的記憶體。

非角色模型關閉骨架功能

在預設情況下,Unity 會幫非角色的模型放入一個通用骨架(Generic rig),如果這個模型執行時被實例化,會導致被加上一個 Animator 元件。如果這個物件沒有用到 Mecanim 動畫系統就會產生不必要的開銷,因為所有啟動中的 Animator 每幀都會被觸發一次。

關閉沒有動畫的物件的骨架來確保他們不會在執行期間被加上 Animator 元件,造成不必要的消耗。

幫帶有動畫的物件開啟 Optimize Game Objects 選項

Optimize Game Objects 選項對於帶動畫的模型有明顯的效能影響。如果這裡沒打勾,在初始化模型時 Unity 會把整個模型的骨架結構對應的 GameObject 階層全部產生出來。這個巨大的 Transform 元件結構更新起來自然很耗效能,尤其是如果結構帶有其他元件(例如粒子系統或碰撞體)。它也會拉低 Unity 多執行緒對蒙皮(Mesh skinning)和骨架動畫的計算能力。

打勾後所有的骨架對應的 Transform 結構都會被移除,如果模型骨架結構中有特定的部位需要露出方便控制(例如模型的手部要用來握住武器),則可以把它列在“ExtraTransforms”白名單中。

更多資料可以參閱官方手冊關於 Model Importer 的部分。

如果可以,儘量使用Mesh壓縮

開啟 Mesh compression 選項會縮短用來表示模型資料不同通道的浮點數位元長度,這會移除一定的精確度並可能造成可見的變化,使用這個之前最好先讓美術檢查過這種損失在允許範圍內。

各個壓縮等級使用的位元長度在 ModelImporterMeshCompression 腳本裡有介紹。

請記得,可以針對不同通道使用不同等級的壓縮,所以專案可以只針對切線向量(Tangent)和法向量(Normal)壓縮但不壓縮 UV 和頂點位置。

追記:Mesh Renderer 的設定

當加入一個 Mesh Renderer 到 Prefab 或 GameObjec t時,請小心上面的設定。預設情況下 Unity 會開啟 Shadow casting、Shadow receiving、Light Probe 取樣、Reflection Probe 取樣和動態向量(Motion Vector)的計算。

假如專案不需要上述的功能,記得寫個編輯器腳本關掉素材上的選項,執行期用程式加 MeshRenderer 到物件上也要記得執行相同的規則。

對於一個2D遊戲來說,不小心加了一個帶有 Shadow casting 的 MeshRenderer 到場景裡會觸發整個陰影計算循環,這通常是浪費效能。

Audio - 音效

採用平台支援的壓縮設定

採用硬體支援的音源壓縮格式。所有的iOS設備都有 MP3 硬體解壓縮能力,而大多數的 Android 設備都有支援 Vorbis。
此外,可以直接將未壓縮的音效檔匯入 Unity 裡,因為 Unity 會在打包專案時會重新壓縮。所以不需要先壓縮再匯入 Unity,這只會降低音效品質。

強制音效用單聲道

只有少數的手機裝置真的有立體聲喇叭,而將音效強制設定為單聲道能讓記憶體的消耗減半。就算遊戲會輸出部份的立體聲,有些單聲道像是 UI 音效還是可以開啟這個選項。

降低音頻的取樣

調低取樣能進一步降低記憶體消耗和最終專案的大小,可以和音效設計師協調找出最小最能接受的音源品質。參考 SetCompressionBitrate。


Unity最佳化 - 目錄


  1. 分析
  2. 記憶體
  3. 協同
  4. 資源審查
  5. GC和Managed Heap
  6. 字串和Text
  7. Resources目錄和一般最佳化
  8. 特別最佳化

最佳實踐 - 了解Unity效能 - 字串和Text

作者:Ian 原文

翻譯:Kelvin Lo / 海龜

字串與 Text

字串和 Text 是 Unity 專案裡常見到影響效能的原因之一。在 C# 裡所有的字串都是不可變(Immutable)的。任何對字串的操作都會導致配置一個全新的字串,這其實還蠻貴的。如果重複地串接大字串、或是串接數量很多的字串、或是在執行很多次的迴圈裡串接都會造成效能問題。

此外,由於 N 個字串的串接需要配置 N-1 個中介字串,因此這種字串串接容易造成記憶體壓力。

對於必須要在每幀或是在迴圈內處裡這種串接字串的專案,請使用 StringBuilder 來執行串接字串,實體化的 StringBuilder 可以被重複使用,以最小化記憶體開銷。

微軟有份 C# 字串最佳實踐,可以在這裡找到。

強制語系轉化和序數比較法 

很多字串相關的程式的效率問題來自誤用預設較慢的字串 API。這些 API 是為了商業應用而建立的,它們會嘗試分析處理來自各種不同文化和語言規則的字串。

舉例來說,這段程式碼在 US-English 語言環境下執行時會回傳 true,但對許多歐洲語言環境會回傳 false。

注意,從 Unity 5.3 和 5.4 開始,Unity 腳本執行時都會預設用 US English (en-US) 的環境來執行。
String.Equals("encyclopedia", “encyclopædia”);

對於大多數 Unity 專案,這是完全沒必要的。使用序數比較(Ordinal)會大約快 10 倍,它採用 C 和 C++ 程式設計師比較慣用的比對方式,即逐位元比對而非找出位元構成的字元再判斷兩個字元在目前語系是否等價。

改用序數字串比對方法只要像原本 String.Equals 一樣寫法,後面加上 StringComparison.Ordinal 參數即可。
myString.Equals(otherString, StringComparison.Ordinal);


效能低落的內建字串 API

除了改用序數比對法之外,有些 C# 內建的字串 API 已知效率不好。其中包含 String.Format、String.StartsWith 和 String.EndsWith。String.Format 很難被替換,但其他兩個效率不好的比對方法比較容易最佳化。

雖然微軟的建議是一樣將 StringComparison.Ordinal 用在這些不需要考慮語系的字串比對上,但從 Unity 效能分析結果來看會發現有沒有用序數比對的進步跟跟自己手寫一個比對比起來算是九牛一毛。

MethodTime (ms) for 100k short strings
String.StartsWith, default culture137
String.EndsWith, default culture542
String.StartsWith, ordinal115
String.EndsWith, ordinal34
Custom StartsWith replacement4.5
Custom EndsWith replacement4.5

String.StartsWith 和 String.EndsWith 都可以手動換成像是下方的簡單範例。

public static bool CustomEndsWith(string a, string b) { int ap = a.Length - 1; int bp = b.Length - 1; while (ap >= 0 && bp >= 0 && a [ap] == b [bp]) { ap--; bp--; } return (bp < 0 && a.Length >= b.Length) || (ap < 0 && b.Length >= a.Length); } public static bool CustomStartsWith(string a, string b) { int aLen = a.Length; int bLen = b.Length; int ap = 0; int bp = 0; while (ap < aLen && bp < bLen && a [ap] == b [bp]) { ap++; bp++; } return (bp == bLen && aLen >= bLen) || (ap == aLen && bLen >= aLen); }

正規表示法 

雖然這種表示法是一種強大的比對和操作字串的方法,但效能代價可能也很高。此外,由於 C# 函式庫的正規表示法的關係,即使是很簡單的 isMatch 查詢也會在幕後配置很多暫用的資料結構。除了在初始化期間,這種短暫的記憶體爆發應該被認為不可接受的。

所以如果需要正規表示法,強烈建議不要使用接受正規表示法字串作為參數的靜態 Regex.Match 或 Regex.Replace 方法。這些方法都是當場編譯正規表示法後用過即丟。

底下這個範例程式看起來人畜無害。

Regex.Match(myString, "foo");

但每次執行都會產生 5k 的垃圾。可以簡單修改一下來解決:

var myRegExp = new Regex("foo"); myRegExp.Match(myString);

這個範例每次呼叫 myRegExp.Match“只會”產生 320byte 的垃圾,雖然還是有相當代價,但比起 5k 是好多了。

因此如果正規表示法是不變的字串文字,把它們作為 Regex 物件的建構子的第一個參數來預編譯。這些預編譯的正規表示法應該要重複使用。

XML、JSON 和其他大型文字的解析

分析文字通常是載入時最耗效能的操作之一,在某些情況下,分析文字所花費的時間可能超過載入和實體化資源所花的時間。

背後的原因得看用甚麼解析器(parser),C# 的內建 XML 解析器是很靈活的,也因為如此,它並沒有對特定資料結構最佳化。

許多第三方解析器都是架構在反射(Reflection)上,雖說反射是開發中一個很好的解決方案(因為它能讓解析器快速適應資料架構變化),但是它也是眾所皆知的慢。

Unity 導入了一個內建 JSONUtility API 的解決方案,它為 Unity 序列化系統(Serialization system)提供了一個讀寫 JSON 的介面。從各方面的效能分析來看,它甚至比純 C# JSON 解析器更快,但它和 Unity 的序列化系統其他介面有著相同的限制:在使用者改寫之前無法序列化許多複雜的資料結構,像是 Dictionary。(參考 ISerializationCallbackReceiver 介面的說明來了解如何在 Unity 的序列化系統上處理這些資料結構)

如果你遇到這裡提到的資料解析所產生的效能問題,可以考慮三種替代方案。

方案一:在打包時解析

要避免過長的文字解析時間成本,最好的方法就是在執行時不要有文字解析的操作。一般來說就是透過某些流程將文件資料先“烘焙”成二進制格式。

大多數選擇這條路的開發者會將數據資料移到某種繼承 ScriptableObject 的類別,然後用 AssetBundles 來打包。關於 ScriptableObjects 的詳細討論,可以參考 Richard Fine 在 Unite 2016 的演講。

這個方案能提供最好的效能,但只適合用在不需要動態產生的資料。比如說它很適合用在遊戲設計參數等不會變動的內容。

方案二:分解(Split)和延遲(Lazy)載入

第二種可能性是將需要分析的資料分成小塊,分成小塊之後,解析的效能成本可以分攤在幾個幀上。甚至可以判斷並只解析客戶端需要顯示的特定部分。只載入那些部分。

一個簡單的例子,如果專案是一個闖關遊戲,並不需要把所有的關卡資料序列化一股腦的全載入。如果關卡資料被分割為一關一個獨立的資源檔,甚至每關再分割成不同區域打包,就可以在玩家接近時才解析下一區域。

這聽起來很容易,但實際上要花很多心思在建構工具上,而且還可能需要重新定義你的資料結構。

方案三:執行緒

對於完全解析為純 C# 物件,不需要和 Unity API 有任何互動的資料,可以將解析操作移到工作執行緒(Worker threads)。

這個方案針對擁有多核的平台很有優勢(目前 iOS 裝置最多兩核,Android 大多二到四核,這項技術最適合用在更多資源的電腦和家用主機上),但是它需要很小心編寫程式以免發生 Deadlocks 和 Race conditions 的問題。

選擇要和執行緒奮鬥的勇者可以使用內建的 C# Thread 和 ThreadPool 類別來管理工作執行緒,還有標準 C# 同步(Synchronization)類別。

Unity最佳化 - 目錄


  1. 分析
  2. 記憶體
  3. 協同
  4. 資源審查
  5. GC和Managed Heap
  6. 字串和Text
  7. Resources目錄和一般最佳化
  8. 特別最佳化

最佳實踐 - 了解Unity效能 - GC和Managed Heap

作者:Ian 原文

翻譯:Kelvin Lo / 海龜


了解 Managed Heap 


另一個常見的 Unity 開發者會遇到的問題是 Managed Heap 非預期地擴展,在 Unity 裡 Managed Heap 展開比縮回容易得多。此外,Unity 使用的垃圾回收機制會讓記憶體容易破碎化,這會妨礙 Heap 縮減。 


技術細節:Managed Heap 怎麼運作的以及為何他會擴大 

Managed Heap 是專案程式執行時由執行層(Mono 或 IL2CPP)的記憶體管理器(Memory manager)自動管理的記憶體,在 Managed 程式中建立的物件都必須配置在 Managed Heap 上。嚴格來說,所有非 null 參照型別物件(Reference-typed objects)和所有被封箱的數值型別物件(Boxed value-typed objects),必須配置在Managed Heap 上。 


上圖白色區域表示分配給 Managed Heap 的記憶體大小,其中有顏色的區塊表示存在 Managed Heap 記憶體裡面的資料。當需要額外加入數值資料時,會從 Managed Heap 裡分配更多空間出來。 

GC 是週期性地在執行(確切的週期依平台而定),它會掃描堆積上所有的物件,標記所有不再被任何其他物件引用的物件。然後被標記的物件會被刪除,把記憶體釋放出來。 

很重要的是,Unity 的 GC(Garbage Collection)使用 Boehm GC 演算法,是一種 Non-generational和 Non-compacting 的 GC 演算法。”Non-generational”意思是 GC 在執行清理檢查時必須掃描整個堆積,代表隨著堆積的擴展會讓它的效能降低。”Non-compacting”代表清理完畢後,不會重新排序記憶體裡的物件位址來消滅物件之間的空隙。


上圖是記憶體破碎化的範例。當一個物件被釋放時它在記憶體裡的空間也會被釋放,但是釋放出的記憶體空間並不會和其他大塊空白記憶體合在一起。釋放空間的前後兩側都還有使用中的記憶體區塊。因此它就會產生記憶體碎片(紅色圈圈表示),這個空間最後只能存放相等或小於此空間的數據資料。 

記得,物件在配置記憶體時必須占用整塊連續的空間。 

這會導致記憶體破碎的主要問題:就帳面上來說可能還有很多記憶體空間,但實際上這些空間分散在其他物件之間的小縫隙裡。這種情況下,可能導致 Managed Heap 有足夠的總空間但卻找不到夠大的連續空間給比較大塊的物件。 


一旦遇到了上面的狀況,一個較大的物件在無法找到足夠的連續記憶體位置來存放,Unity 會執行兩個動作。 

首先,如果沒有跑過 GC 它會觸發 GC 試圖釋放空間來存放這個物件。 

如果在 GC 執行完畢後仍然沒有足夠空間,就必須要拓展這個堆積。拓展的大小會因為不同平台而有所不同,一般來說是拓展成現有的 Managed Heap 的兩倍。 

堆積的關鍵問題 

  • Unity 不太會釋放已分配給 Managed Heap 的記憶體分頁,就算大部分是空的也會保留下來。主要是怕收縮後不久又要再拓展。 
    • 在多數平台上,Unity 最終還是會把 Managed Heap 記憶體分頁的空白部分釋放回 OS,但時間很不固定所以不能太依賴這個行為。 
  • Managed Heap 使用的虛擬記憶體位址(Address speace)不會還給 OS 。 
    • 對於 32 位元的程式來說,如果 Managed Heap 這樣拓展又縮回很多次可能會導致位址空間耗盡。如果程式可用的記憶體位置空間耗盡可能會造成程式被作業系統終止。 
    • 對於64位元的程式來說,位址空間絕對夠大的,除非你的程式要執行一百年否則很難發生位址空間不足。 

臨時配置 

我們發現許多 Unity 專案每幀會在 Managed Heap 上配置幾十 kb 或幾百 kb 的臨時資料,這通常對效能很不好。以下是簡單的估算: 

如果程式每幀分配 1kb 的臨時記憶體,且專案以 60FPS 的速度執行。代表每秒會有 60kb 的臨時記憶體產生。一分鐘會有 3.6mb 的垃圾產生。因為效能考量我們不可能每秒呼叫 GC 一次,但在記憶體本來就不多的設備執行每分鐘配置 3.6mb 會有很大的問題。 

此外,考慮到載入的動作。假如載入一個資源時產生大量的臨時物件 ,這些物件會一直被引用直到資源載入完畢,這樣會造成 GC 處裡器無法在載入過程中回收這些臨時物件,導致 Managed Heap 擴展,就算這些物件在載入完成後很快就被標成可回收也無法挽回。 

要追蹤 Managed 記憶體相較之下比較簡單,可以從 Unity 的 CPU Profiler 找到 GC Alloc 這一欄,它能顯示出在特定幀配置了多少 Managed Heap 記憶體(這跟這幀配置了多少「暫用的」記憶體不同,之後還會使用的記憶體也會被加到這項),開啟 Deepprofiling 可以追蹤到產生這些配置的方法。 

要注意有些腳本方法在編輯器執行時會導致記憶體配置,但在輸出專案後並不會產生配置問題。像是GetComponent 是最常見的例子,它會在編輯模式執行記憶體分配,但建置專案後就不會有問題。 

一般來說,我們建議所有開發者在玩家操作階段時儘量避免 Managed heap 配置行為。在非操作階段,比如場景載入時來執行配置比較不會有問題。 

基本記憶體節約 


有些簡單的方法可以減少 Managed Heap 分配。 

重複使用集合(Collection)和陣列(Array) 

當使用 C# 的集合類別或陣列時,盡可能考慮重複利用或物件池化(Pooling)配置的集合或陣列。Collection 類別有個 Clear 方法能不釋放記憶體但清除集合內的值。 

void Update() {
   List<float> nearestNeighbors = new List<float>();
   findDistancesToNearestNeighbors(nearestNeighbors);
   nearestNeighbors.Sort();
   // … use the sorted list somehow …
}

一個簡單的範例,當你需要不斷配置暫用的集合來幫助你運算的時候,利用 Clear 可以解決你的配置問題。 

上面的程式中每幀會配置一次 nearestNeighbors 這個 List 來暫存一組資料。可以將這個宣告從 Update 方法提出到外面的類別定義裡,就能避免每幀配置一個新的 List。 

List<float> m_NearestNeighbors = new List<float>();
void Update() {
   m_NearestNeighbors.Clear();
   findDistancesToNearestNeighbors(NearestNeighbors);
   m_NearestNeighbors.Sort();
   // … use the sorted list somehow …
}

用這個修改過的版本,List 的記憶體會保留並在不同的幀被拿來重複利用。除非這個 List 需要擴展才會配置新的記憶體。 

閉包(Closures)和匿名方法 

使用這兩個語言提供的功能要考慮到以下兩點。 

首先,C# 裡的所有方法的參考都是參考型別(Reference type)所以都是在堆積上配置。將方法的參考當成參數傳遞就會造成記憶體配置,不管正在傳遞的方法是匿名方法還是預先定義的方法。 

其次,將匿名方法轉換成閉包會增加傳遞閉包給接收它作為引數的方法所需要的記憶體大小。 

看看這段程式碼: 

List<float> listOfNumbers = createListOfRandomNumbers();
listOfNumbers.Sort( (x, y) =>
(int)x.CompareTo((int)(y/2))
);

這段程式用一個簡單的匿名方法來排序第一行宣告的清單列表。但是如果有個程式設計師希望重複利用這段程式,他可能會想把常數 2 換成一個區域變數,像是這樣: 

List<float> listOfNumbers = createListOfRandomNumbers();
int desiredDivisor = getDesiredDivisor();
listOfNumbers.Sort( (x, y) =>
(int)x.CompareTo((int)(y/desiredDivisor))
);


結果現在這個匿名方法需要存取自己的範疇(Scope)以外的變數所以要變成閉包,desiredDivisor 變數必須以某種方式傳遞給閉包,方便它可以被閉包內的程式取用。 

為了達成這目的,C# 會產生一個匿名的類別用來保存閉包所需要的外部變數,當這個閉包傳遞給 Sort方法時,會實體化一個匿名類別的物件,並用 desiredDivisor 整數的值來初始化這個物件。 

因為執行閉包需要實體化產生的類別,且所有的類別的實體都是 C# 中的參考型別,所以執行閉包需要在 Managed Heap 上進行配置。 

一般來說,C# 中最好避免閉包,儘量少用匿名方法和方法參考(Method references)在和效能相關的程式碼裡,特別是那種每一幀都會執行的程式碼。 

IL2CPP 底下的匿名方法 

目前,查看由 IL2CPP 產生的程式碼會你發現對 System.Function 類型的變數做簡單的宣告或是賦值都會產生一個新物件。無論是變數是顯性宣告(在方法或類別定義宣告)或隱性的(宣告在另一個方法的參數)都是如此。 

因此在 IL2CPP 環境下任何使用匿名方法都會配置 Managed 的記憶體。使用 Mono 則不會發生。 

此外,IL2CPP 會因為方法的指向另一個方法的參數宣告方法不同而會有不同的 Managed 記憶體配置量。如預期般地用閉包來呼叫會配置最多的記憶體。 

但違反一般人的直覺地,預先定義的方法在 IL2CPP 環境下作為參數傳遞時,配置的記憶體幾乎和閉包一樣多。而匿名方法在堆積上產生的臨時垃圾最少,少了一個以上的數量級。 

因此,如果專案打算用 IL2CPP 做為執行環境,有三點建議: 
  • 建議程式風格避免傳遞方法作為參數。 
  • 真的無法避免的話,採用匿名方法而非預先定義方法。 
  • 不管 Mono 或 IL2CPP 都避免使用閉包。 

Boxing - 封箱 

封箱(Boxing)是 Unity 專案中發現非預期的記憶體配置最常見的問題來源,當一個數值型別被當成參考型別使用時就會發生,通常是將基本(Primitive)的數值型別(int 或 float)傳給接收 object 類型作為參數方法時。 

在這個例子裡,x 這個整數被封箱後傳遞給 object.Equals 方法,因為 object 上的 Equals 方法接收的參數型別是object。 

int x = 1;
object y = new object();
y.Equals(x);

C# 的 IDE 介面和編譯器通常不會警告這種封箱行為,即使它造成一些意外的記憶體配置。這是因為 C# 語言設計時假設 GC 處理器和依照配置大小分類的記憶池能有效地處理這種較小的臨時分配。 

雖然 Unity 的配置器(Allocator)會針對不同大小的配置使用不同的記憶池,但 Unity 的 GC 回收器如上面所提是“non-generational”類型的,因此對於封箱所產生頻繁的記憶體碎片無法有效的清理。 

在 Unity 底下寫 C# 程式應該極力避免造成封箱。 

找出封箱問題所在 


發生封箱時會以底下其中之一的方法呼叫出現在 CPU 追蹤上,使用 Mono 或是 IL2CPP 會造成變化,其中的<some class> 是類別名稱,…代表一些參數。 
  • <some class>::Box(…) 
  • Box(…) 
  • <some class>_Box(…) 
你也可以搜索反編譯器或 IL 檢視器(Intermediate Language Viewer)所輸出的資料,比如ReSharper 裡內建的 IL 檢視工具或是 dotPeek 反編譯器。裝箱的 IL 指令是“box”。 

Dictionaries 和 Enums 

發生封箱一個常見的原因是使用 enum 類型作為 Dictionary 的 Key,宣告一個 enum 會建立一個新的數值類別其運作方式像是普通的整數,但在編譯時會進行類別檢查(Type-safety)。 

在預設的情況下,呼叫 Dictionary.add(key, value) 會呼叫到 Object.getHashCode(Object)。這個方法用來取得 Dictionary key 的雜湊值,它也會在 Dictionary.tryGetValue、Dictionary.remove 等等方法執行中呼叫到。 

Object.getHashCode 方法是傳參考類型的,但 enum 值永遠是數值類型。因此使用 enum 作為 key 類型的 Dictionary,每次的方法呼叫會導致 key 被封箱一次以上。 

以下程式碼說明這個封箱問題 

enum MyEnum { a, b, c };
var myDictionary =
new Dictionary<MyEnum, object>();

myDictionary.Add(MyEnum.a, new object());


要解決這個問題,需要編寫一個自訂類別來實做 IEqualityComparer 介面,並將該類別的實體設定作為 Dictionary 的比較器(註10)。這種物件通常不需要保存狀態,因此不同的 Dictionary 可以共用 IEqualityComparer 物件以節省記憶體。 

以下是簡單的 IEqualityComparer 範例。 

public class MyEnumComparer : IEqualityComparer<MyEnum> {
    public bool Equals(MyEnum x, MyEnum y) {
        return x == y;
    }

    public int GetHashCode(MyEnum x) {
        return (int)x;
    }
}

上面類別的實體可以傳遞給 Dictionary 的建構子(Constructor)。 

Foreach 迴圈 

在 Unity 使用的舊版 Mono C# 編譯器上使用 foeach 迴圈會迫使 Unity 每次迴圈結束時都封箱一次(每次迴圈完成時,這個值就會被封箱。並非在迴圈每次迭代封箱,所以迴圈不管是跑 2 次還是 200 次封箱記憶體用量都是一樣的。)這是因為 Unity 的 C# 編譯器產生的 IL 會建構一個數值類別的泛型Enumerator 來迭代這個集合。 

這個 Enumerator 實做了 IDisposable 介面,當迴圈終止時必定會呼叫 IDisposable 上的 Dispose。但是,透過介面呼叫一個數值類型物件(例如 struct 和 Enumerator)就必須要封箱它們。 

一個簡單的範例 

int accum = 0;
foreach(int x in myList) {
   accum += x;
}

經過 Unity 的 C# 編譯器會變成以下的 IL 程式: 

.method private hidebysig instance void
    ILForeach() cil managed
  {
    .maxstack 8
    .locals init (
      [0] int32 num,
      [1] int32 current,
      [2] valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32> V_2
    )

    // [67 5 - 67 16]
    IL_0000: ldc.i4.0    
    IL_0001: stloc.0      // num

    // [68 5 - 68 74]
    IL_0002: ldarg.0      // this
    IL_0003: ldfld        class [mscorlib]System.Collections.Generic.List`1<int32> test::myList
    IL_0008: callvirt     instance valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<!0/*int32*/> class [mscorlib]System.Collections.Generic.List`1<int32>::GetEnumerator()
    IL_000d: stloc.2      // V_2
    .try
    {

      IL_000e: br           IL_001f

    // [72 9 - 72 41]
      IL_0013: ldloca.s     V_2
      IL_0015: call         instance !0/*int32*/ valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::get_Current()
      IL_001a: stloc.1      // current

    // [73 9 - 73 23]
      IL_001b: ldloc.0      // num
      IL_001c: ldloc.1      // current
      IL_001d: add         
      IL_001e: stloc.0      // num

    // [70 7 - 70 36]
      IL_001f: ldloca.s     V_2
      IL_0021: call         instance bool valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::MoveNext()
      IL_0026: brtrue       IL_0013

      IL_002b: leave        IL_003c


} // end of .try
    finally
    {
      IL_0030: ldloc.2      // V_2
      IL_0031: box          valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>
      IL_0036: callvirt     instance void [mscorlib]System.IDisposable::Dispose()
      IL_003b: endfinally  
    } // end of finally
    IL_003c: ret         

  } // end of method test::ILForeach
} // end of class test

關鍵的部分在接近尾端的 __finally { … }__ ,callvirt 指令在呼叫方法之前在記憶體中發現IDisposable.Dispose 方法的位置,並要求 Enumerator 要封箱。 

一般來說,Unity 裡應該儘量避免用 foreach 迴圈,不只是會產生封箱的問題,這種透過 Enumerator迭代集合的方式呼叫成本通常比用 for 或 while 迴圈等手動迭代慢得多。 

請注意,Unity 5.5 的 C# 編譯器升級明顯提升了 Unity 產生 IL 的能力。在 foreach 迴圈中所產生的封箱問題已經消除。當然就解決了 foreach 迴圈相關的記憶體消耗。但是,相較採用基於 Array 的程式相比,CPU 效能還是因為涉及方法呼叫而差一截。 

回傳陣列的 Unity API 

有一種比 foreach 還要惡毒的坑是不小心去持續呼叫回傳陣列的 Unity API。所有的 Unity API 在回傳陣列時都會建立一個新的陣列副本。所以非必要建議少用會回傳陣列的 Unity API。 

這段範例程式在每個循環迭代中建立了四個 vertices 陣列的副本,每次取用 .vertices 就會創造一個。 

for(int i = 0; i < mesh.vertices.Length; i++)
{
    float x, y, z;
    x = mesh.vertices[i].x;
    y = mesh.vertices[i].y;
    z = mesh.vertices[i].z;
    // ...
    DoSomething(x, y, z);  
}

透過進入迴圈之前先把頂點陣列暫存起來,就能將陣列配置限制到只有一個。 

var vertices = mesh.vertices;
for(int i = 0; i < vertices.Length; i++)
{
    float x, y, z;
    x = vertices[i].x;
    y = vertices[i].y;
    z = vertices[i].z;
    // ...
    DoSomething(x, y, z);  
}

雖然存取屬性一次花的 CPU 成本沒有很高,但在一個迴圈循環內重複的存取還是會影響 CPU 效能,此外,這樣的行為也會造成不必要的 Managed heap 記憶體擴展。 

這個問題很常出現在手機設備,因為 Input.touches API 的行為就和上面所說的 .vertices 很像,常常會看到專案有這樣的程式碼,每次 .touch 被存取時就會有配置: 

for ( int i = 0; i < Input.touches.Length; i++ )
{
   Touch touch = Input.touches[i];
    // …
}

這問題當然是可以把陣列宣告跟存取拉出迴圈來解決。 

Touch[] touches = Input.touches;
for ( int i = 0; i < touches.Length; i++ )
{
   Touch touch = touches[i];
   // …
}

但是現在許多新版 Unity API 呼叫時不會配置陣列,當有新 API 可用時通常建議直接使用它們: 

int touchCount = Input.touchCount;
for ( int i = 0; i < touchCount; i++ )
{
   Touch touch = Input.GetTouch(i);
   // …
} 

像上面範例裡的轉換應該不會太複雜。 

注意,存取 Input.touchCount 屬性仍然保持在迴圈之外,以便節省呼叫屬性的 get 方法造成的 CPU 消耗。 

空陣列重複利用 


當一個回傳陣列的方法需要回傳一個空集合時,比起回傳 null 值,有些團隊更喜歡傳回空陣列。這種寫作方式在許多語言還蠻常見的,特別是 C# 和 Java。 

通常,當從方法回傳一個 0 長度的陣列時,可以回傳一個在 Singleton 上預先配置好的長度為 0 的陣列,比重複建立空陣列好得多。當然,當這個空陣列回傳後被改變大小應該要丟出異常警告。 

Unity最佳化 - 目錄


  1. 分析
  2. 記憶體
  3. 協同
  4. 資源審查
  5. GC和Managed Heap
  6. 字串和Text
  7. Resources目錄和一般最佳化
  8. 特別最佳化

著作人

網誌存檔