概述
本文就Unity游戲項目性能優(yōu)化作出了總結(jié)。包括Profile工具、Unity使用、機制設計、腳本編寫等方面內(nèi)容。
本文的測試機型皆為iPhone6。為方便找出瓶頸目標幀率先提高為60fps,后面再看實際情況是否限幀30fps。
本文的Unity版本為5.5.0f3或更新版本。
本文將持續(xù)更新。
Profiler工具
在Unity項目中,可能使用到的Profiler工具分3種:
- 長期性能數(shù)據(jù)監(jiān)控工具
- Unity Profiler
- XCode和Instruments
長期性能數(shù)據(jù)監(jiān)控工具會至少每天都對游戲單局、或游戲資源進行自動化性能測試,并上報結(jié)果到服務器。能從“整體”去對比不同時段、不同版本間的性能差別。
Unity Profiler能定量地找到C#的GC Alloc問題;其Timeline視圖也能從地整體(但不太定量)找到CPU瓶頸。
XCode的GPU Report視圖能從整體(但不太定量)找到游戲的瓶頸階段。當Frame Time中CPU大于GPU時,表示CPU是瓶頸,否則表示GPU是瓶頸;當Utilization中的TILER比RENDERER高時,表示頂點處理是GPU的瓶頸,否則表示像素處理是GPU的瓶頸。
幀率受限于瓶頸,應優(yōu)先優(yōu)化瓶頸階段,非瓶頸階段優(yōu)化得再快都無法提高幀率。
XCode的Capture GPU frame功能能高效且定量地定位到GPU中shader的消耗。
Instruments的TimeProfiler能高效且定量地定位C#腳本(IL2CPP后的C++代碼)的CPU占用,甚至包括部分Unity引擎代碼的CPU占用函數(shù)消耗,而不必麻煩地添加
BeginSample()
、EndSample()
。Unity使用/機制優(yōu)化小結(jié)
GameObject的SpawnPool應支持“移出屏幕”功能
GameObject(比如特效)可能會被頻繁的在“使用中”、“不使用”的狀態(tài)間切換。我們的SpawnPool不應過快地把“剛剛不使用”的GameObject立刻Deactivate掉,否則會引起不必要的Deactivate/Activate的性能消耗。應有一個“從熱變冷”的過程:“剛剛不使用”只是移出屏幕;只有“不使用一段時間”的GameObject,才會得以Deactivate。可能的實現(xiàn)方式如下:
/// On each timer, we try to make parts of "hot" items to be "cool" by deactivating them.
internal void OnTimer()
{
if(teleportCache.items.Count > 0) {
if(Time.realtimeSinceStartup - teleportCache.lastSpawnTime < SpawnPool.TeleportThenDeactivateDuration &&
teleportCache.items.Count <= 3) {
this.MoreLogInfo("this prefab is recently spawned, and the teleport cache is not too large, we don't deactivate these remaining items");
}
else {
int deactivateCount = Mathf.Max(1, teleportCache.items.Count / 3);
SpawnIdentity oneId;
while(deactivateCount > 0) {
--deactivateCount;
oneId = teleportCache.items.Pop();
if(null != oneId) {
this.MoreLogInfo("Deactivate from teleportCache:" + oneId);
oneId.gameObject.MoreSetActive(false, this);
oneId.gameObject.transform.SetParent(null, true);
deactivateCache.Push(oneId);
SpawnPoolProfiler.AddDeactivateCount(oneId.gameObject);
}
}
}
}
}
Transform的孩子不應過多
當Transform包含不該有的孩子Transform或其他組件時,為該Transform進行position、rotation賦值,會引起消耗,特別是包含粒子系統(tǒng)的時候。
但考慮到切換Transform的parent本身也會有消耗,因此,我們對此也應有“從熱變冷”的過程:“剛剛不使用”依然保留在父親Transform里;只有“不使用一段時間”的GameObject,才從父親Transform移出。
應減少粒子系統(tǒng)的Play()的調(diào)用次數(shù)
每次調(diào)用ParticleSystem.Play()
都會有消耗,如果粒子系統(tǒng)本身沒有明顯“前搖”階段,應先檢查ParticleSystem.isPlaying
,例子如下:
ParticleSystem ps;
for (int i = 0; i < num; ++i) {
//m_particleSystemLst[i].Stop();
ps = m_particleSystemLst[i];
/// CAUTION! WE SHOULD CHECK isPlaying before calling Play()! OR, IT WILL AFFECT PERFORMANCE!
if(!ps.isPlaying) {
ps.Play();
}
}
應減少每幀Material.GetXX()/Material.SetXX()的次數(shù)
每次調(diào)用Material.GetXX()
或Material.SetXX()
都會有消耗,應減少調(diào)用該API的頻率。比如使用C#對象變量來記錄Material的變量狀態(tài),從而規(guī)避Material.GetXX()
;在Shader里把多個uniform half
變量合并為uniform half 4
,從而把4個Material.SetXX()
調(diào)用合并為1個Material.SetXX()
。
應使用支持Conditional的日志輸出機制
簡單使用Debug.Log(ToString() + "hello " + "world");
,其實參會造成CPU消耗及GC。使用支持Conditional的日志輸出機制,則無此問題,只需在構(gòu)建時,取消對應的編譯參數(shù)即可。
/// MoreDebug.cs,帶Conditional條件編譯的日志輸出機制
[Conditional("MORE_DEBUG_INFO")]
public static void MoreLogInfo(this object caller) { DoMoreLog(MoreLogLevel.Info, false, caller); }
/// 用戶代碼.cs,調(diào)用方簡單正常調(diào)用即可。正式構(gòu)建時,取消MORE_DEBUG_INFO編譯參數(shù)。
this.MoreLogInfo("writerSize=" + writer.Position, "channelId=" + channelId);
腳本優(yōu)化小結(jié)
依然需要減少GetComponent()的頻率
如GetComponent()
能成功找到組件,則無GC消耗,如找不到則會有一定的GC產(chǎn)生。有少量的CPU消耗。如有可能,我們依然需要規(guī)避冗余的GetComponent()
,特別需要避免用其查找可能不存在的組件。另,自Unity5起,Unity已就.transform
進行了cache,我們不需再為.transform
擔心,見《UNITY 5: API CHANGES & AUTOMATIC SCRIPT UPDATING》最后一段。
應減少UnityEngine.Object的null比較
因為Unity overwrite掉了Object.Equals()
,《CUSTOM == OPERATOR, SHOULD WE KEEP IT?》也說過unityEngineObject==null
事實上和GetComponent()
的消耗類似,都涉及到Engine層面的機制調(diào)用,所以UnityEngine.Object的null比較,都會有少許的性能消耗。對于基礎功能、調(diào)用棧葉子節(jié)點邏輯、高頻功能,我們應少null比較,使用assertion來處理。只有在調(diào)用棧根節(jié)點邏輯,有必要的時候,才進行null比較。
而且,從代碼質(zhì)量來看,無腦的null保護也是不值得推崇的,因為其將錯誤隱藏到了更偏離錯誤根源的邏輯。理論上,當錯誤發(fā)生了,應盡早報錯,從而幫助開發(fā)者能更快速地定位錯誤根源。所以,多用assertion,少用null保護,無論是對代碼質(zhì)量,還是代碼性能,都是不錯的實踐。
應減少不必要的Transform.position/rotation等訪問
每次訪問Transform.position/rotation都有相應的消耗。應能cache就cache其返回結(jié)果。
應盡量減少創(chuàng)建C#堆內(nèi)存對象
建議使用成員變量,或者Pool來規(guī)避高頻創(chuàng)建C#堆內(nèi)存對象的創(chuàng)建。而且堆內(nèi)存對象創(chuàng)建本身就是個相對較慢的過程。
應為struct對象重載所有object函數(shù)
為了普適性,C#的struct的默認Equals()
、GetHashCode()
和ToString()
都是較慢實現(xiàn),甚至涉及反射。用戶自定義的struct,都應重載上述3個函數(shù),手動實現(xiàn),比如:
public struct NetworkPredictId{
int m_value;
public override int GetHashCode(){
return m_value;
}
public override bool Equals(object obj){
return obj is NetworkPredictId && this == (NetworkPredictId)obj;
}
public override string ToString(){
return m_value.ToString();
}
public static bool operator ==(NetworkPredictId c1, NetworkPredictId c2){
return c1.m_value == c2.m_value;
}
public static bool operator !=(NetworkPredictId c1, NetworkPredictId c2){
return c1.m_value != c2.m_value;
}
}
如果可能,盡量用Queue/Stack來代替List
我們會習慣用List
來實現(xiàn)數(shù)據(jù)集合的需求。但好一些情況下,我們事實上是不需對其進行隨機訪問,而僅僅是“增加”、“刪除”操作。此時,我們應該使用增刪復雜度都是O(1)的Queue
或者Stack
。
注意List常用接口復雜度
Add()
常為O(1)復雜度,但超過Capacity時,為O(n)復雜度。故我們應注意合理地設置容器的初始化Capacity。
Insert()
為O(n)復雜度。
Remove()
為O(n)復雜度。RemoveAt(index)
為O(n)復雜度,n=(Count - index)。故建議移除時應優(yōu)先從尾部移除。當批量移除時,miloyip亦指出RemoveRange提高移除效率。
/// remove not exsiting items in O(n)
int oldCount = m_items.Count;
int newCount = 0;
Item oneItem;
for(int i = 0; i < oldCount; ++i){
oneItem = m_items[i];
if(CheckExisting(oneItem)){
m_items[newCount] = oneItem;
++newCount;
}
}
m_items.RemoveRange(newCount, oldCount - newCount);
應注意容器的初始化capacity
同理如上條目。另,Capacity增長時,除了O(n)的復雜度,也有GC消耗。
應盡量為類或函數(shù)聲明為sealed
IL2CPP就sealed的類或函數(shù)會有優(yōu)化,變虛函數(shù)調(diào)用為直接函數(shù)調(diào)用。詳見《IL2CPP OPTIMIZATIONS: DEVIRTUALIZATION》。
C#/CPP interop時,不需為blittable的變量聲明為MarshalAs
某些數(shù)值類型,托管代碼和原生代碼的二進制表達方式一致,這些稱為blittable數(shù)值類型。blittable數(shù)值類型在interop時為高效的簡單內(nèi)存拷貝,故應值得推崇。C#中的blittable數(shù)值類型為byte
、int
、float
等,但注意不包括常用的bool
、string
。僅有blittable數(shù)值類型組成的數(shù)組或struct,也為blittable。
blittable的變量不應聲明MarshalAs
。
比如下面代碼,
[DllImport(ApolloCommon.PluginName, CallingConvention = CallingConvention.Cdecl)]
private static extern ApolloResult apollo_connector_readUdpData(UInt64 objId, /*[MarshalAs(UnmanagedType.LPArray)]*/ byte[] buff, ref int size);
注釋前后的IL2CPP代碼如下圖,右側(cè)明顯避免了marhal的產(chǎn)生。
詳見《IL2CPP INTERNALS: P/INVOKE WRAPPERS》。
減少Dictionary的冗余訪問
我們常習慣編寫這樣的代碼:
if(myDictionary.Contains(oneKey))
{
MyValue myValue = myDictionary[oneKey];
// ...
}
但其可減少冗余的哈希次數(shù),優(yōu)化為:
MyValue myValue;
if(myDictionary.TryGetValue(oneKey, out myValue))
{
// ...
}