近期在維護知乎專欄,簡書更新比較不即使,偷懶沒寫markdown,只粘貼內容過來好了。
代碼部分粘貼過來比較亂,知乎傳送門:https://zhuanlan.zhihu.com/p/33458843
1. 前言
近期斷斷續續地做了一些優化的工作,包括資源加載、ui優化、效果分級各個方面。優化本身是一件瑣碎且耗神的事情,需要經歷問題定位、原因探查、優化方案設計和實現、效果驗證、資源修改多個步驟,也會涉及到各個職位之間的配合和協調。在這其中,可能帶來較大工作量的是對于之前普遍使用的一些方法/控件的優化,如果無法兼容之前的使用接口,可能會給美術和程序帶來較大的迭代工作量。
UI是這其中可能越早發現問題收益越高的一塊內容,所以整理一下這段時間做了一些基于Shader來進行優化的思路和方法,以及分享一下自己構建的代替ugui提供的通用控件的那些Component,希望在項目中前期的同學可以提前發現類似的問題進行盡早的改進。
2. 優化目標
ugui已經提供了非常豐富的控件來給我們使用,但是出于通用性的考慮,其中很多控件的性能和效果都可能存在一些問題,又或者在頻繁更改ui數值的需求下會引發持續的Mesh重建導致CPU的消耗。我們期望通過一些簡單的Shader邏輯來提升效果或者提高效率,主要會集中在如下幾個方面:
降低Draw Call;
減少Overdraw;
減少UI Mesh重建次數和范圍。
接下來的內容,我們就從具體的優化內容上來分享下使用簡單的Shader進行UGUI優化的過程。
3. 小地圖
在我們游戲中,玩家移動的時候右上角會一直有小地圖的顯示,這個功能在最初的實現方案中是使用ugui的mask組件來做的,給了一個方形的mask組件,然后根據玩家位置計算出地圖左下角的位置進行移動。這種實現方式雖然簡單,但是會有兩個問題:
Overdraw特別大,幾乎很多時候會有整個屏幕的overdraw;
玩家在移動過程中,因為一直在持續移動圖片的位置(做了適當的降頻處理),所以會一直有UI的Mesh重建過程。
當時的prefab已經被修改了,我簡單模擬一下使用Mask的方法帶來的Overdraw的效果如下圖所示:
在上圖中可以看到,左側是小地圖在屏幕中的效果,右側是選擇Overdraw視圖之后的效果,整張圖片都會有一個繪制的過程,占據幾乎整個屏幕(白框),而且Mask也是需要一次繪制過程,這樣就是兩個Drawcall。其實這里ui同學為了表現品質感,在小地圖上又蒙了一層半透的外框效果,消耗更大一些。
針對這一問題,首先對于矩形的地圖,可以使用運行效率更高一些的RectMask2D組件,但這并不能有本質的提升,解決Overdraw最根本的方法還是不要繪制那么大的貼圖然后通過蒙版或者clip的方式去掉,這是很浪費的方法。有過基本Shader概念的朋友應該可以想到修改uv的方法,這也是我們采用的方法——思路很簡單,就做一個和要顯示的大小一樣的RawImage控件,然后賦給它一個特殊的材質,在vs里面修改要顯示的區域的uv就可以做到想要的效果。
直接貼出來Shader代碼如下:
sampler2D_MainTex;fixed4_UVScaleOffset;sampler2D_BlendTexture;v2fvert(appdata_tIN){v2fOUT;OUT.vertex=mul(UNITY_MATRIX_MVP,IN.vertex);OUT.texcoord=IN.texcoord;//計算uv偏移OUT.offsetcoord.xy=OUT.texcoord.xy*_UVScaleOffset.zw+_UVScaleOffset.xy;#ifdef UNITY_HALF_TEXEL_OFFSET? ? OUT.vertex.xy-=(_ScreenParams.zw-1.0);#endif? ? returnOUT;}fixed4frag(v2fIN):SV_Target{half4color=tex2D(_MainTex,IN.offsetcoord);half4blendColor=tex2D(_BlendTexture,IN.texcoord);color.rgb=blendColor.rgb+color.rgb*(1-blendColor.a);returncolor;}
核心的代碼就只有加粗的那一句,給uv一個整體的縮放之后再加上左下角的偏移。之后C#邏輯就只需要根據地圖的大小和玩家所在的位置計算出想要顯示的uv縮放和偏移值就可以了。玩家移動的時候只需要修改材質的參數,這也不會導致UI的mesh重建,一箭雙雕,解決兩個問題。
小地圖的外框也在材質中一并做了,減少一個draw call。最終的效果如下圖所示:
這里需要注意的是,對于image控件的material進行賦值時,如果它在一個Mask控件之下,可能會遇到賦值失效的問題,采用materialForRendering或者強制更新材質的方式可能會有新的Material的創建過程導致內存分配,這些在優化之后可能帶來問題的點也是需要優化后進行驗證的。
4. Mask的使用
除了小地圖部分,游戲中比如頭像、技能界面等處都大量地使用了Mask。當然通常情況下Mask不會帶來像小地圖那么高Overdraw,但是因為ugui中的Mask需要一遍繪制過程,因此對于Drawcall的增加還是會有不少。而且Mask也存在邊緣鋸齒的問題,效果上UI同學也不夠滿意,因此我們針對像頭像這樣單張的Mask也進行了一下優化,具體的過程可以參考之前的Unity填坑筆記——《Unity填坑筆記(三)—ugui中針對單獨圖片的Mask優化》,比較詳細地記錄了整個優化過程。
這里補充兩點:
在那篇文章的最后提到,我們自己拷貝了一個ThorImage類,開放部分接口然后繼承。我們后來改成了從Image直接繼承的方式,否則之前編寫的游戲邏輯要在代碼上兼容兩種Image,會比較煩,這些向前兼容的需求也是在優化過程中需要額外考慮和處理的點。
針對滾動列表這樣需要Mask的地方,一方面建議UI同學使用Rect Mask 2D組件,另外一方面為了邊緣的漸變效果為UI引入了Soft Mask插件來提供邊緣的漸變處理。放一張Soft Mask自己的效果對比截圖,需要類似效果的朋友可以自己購買。
UWA針對我們項目的深度優化報告里提到:Soft Mask插件的Component在Disable邏輯里有明顯的性能消耗,目前我們未開始針對這塊進行優化,不過只在ui關閉的時候才有,所以優先級也比較低,想嘗試的朋友可以提前評估下性能。
5. 基于DoTween的動畫效果優化
在游戲中,UI比較大量地使用了DoTween插件制作動畫效果來強調一些需要醒目提醒玩家的信息。DoTween是一個非常好用的插件,無論是對于程序還是對于UI來說,都可以經過簡單的操作來實現較為好的動畫效果。
然而,對于UGUI來說,DoTween往往意味著持續的Canvas的重建,因為動畫通常是位置、旋轉和縮放的變化,這些都會導致其所在的Canvas在動畫過程中一直有重建操作,比如我們游戲中會有的如下圖所示的旋轉提醒的效果:
這一效果在DoTween中通過不斷改變圖片的旋轉來實現的,在我們profile過程中發現了可疑的持續canvas重建,最后通常會定位到類似這樣界面動畫的地方。使用Shader進行優化,只需要把旋轉的過程拿到Shader的vs階段來做,同樣是修改uv信息,材質代碼的vert函數如下:
v2fvert(appdata_tIN){v2fOUT;OUT.vertex=mul(UNITY_MATRIX_MVP,IN.vertex);OUT.texcoord=IN.texcoord;OUT.texcoord.xy-=0.5;//避免時間過長的時候影響精度halft=fmod(_Time.y,2*UNITY_PI);t*=_RotationSpeed;halfs,c;sincos(t,s,c);half2x2rotationMatrix=half2x2(c,-s,s,c);OUT.texcoord.xy=mul(OUT.texcoord.xy,rotationMatrix);OUT.texcoord.xy+=0.5;#ifdef UNITY_HALF_TEXEL_OFFSET? ? OUT.vertex.xy-=(_ScreenParams.zw-1.0);#endifOUT.color=IN.color;returnOUT;}
注意,這里因為要求UI控件使用的是一張RawImage,因此uv的中心點就認為了是(0.5, 0.5)位置。通過參數可以做到從C#中傳遞uv的偏移和縮放信息從而兼容Image,但是因為材質不同導致本來就無法合批,所以使用Image和Atlas帶來的優勢就沒有了,所以這里只簡單地支持RawImage。
Tips:注意所使用貼圖的邊緣處理,因為uv的旋轉可能會導致超過之前0和1的范圍。首先貼圖的采樣方式要使用Clamp的方式,其次貼圖的邊緣要留出幾個像素的透明區域,保證即使在設備上貼圖壓縮之后依然可以讓邊緣的效果正確。
另外使用材質進行優化的地方是自動尋路的提示效果:
最初UI想使用DoTween來制作,但是覺得工作量有點大所以想找程序寫DoTween的代碼進行開發,為了減少ui的Canvas重建,使用材質來控制每一個字的縮放過程。同樣是在vert函數中針對uv進行修改即可:
v2fvert(appdata_tIN){v2fOUT;UNITY_SETUP_INSTANCE_ID(IN);UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT);//根據時間和配置參數對頂點進行縮放操作OUT.worldPosition=IN.vertex;halft=fmod((_Time.y-_TimeOffset)*_TimeScale,1);t=4*(t-t*t);OUT.worldPosition.xy-=_VertexParmas.xy;OUT.worldPosition.xy*=(1+t*_ScaleRatio);OUT.worldPosition.xy+=_VertexParmas.xy;OUT.vertex=UnityObjectToClipPos(OUT.worldPosition);OUT.texcoord=IN.texcoord;OUT.color=IN.color*_Color;returnOUT;}
Tips:這里使用UWA群里一位朋友之前提供的三角函數近似的方法來略微減少一下指令消耗:
這一效果的實現不像之前的旋轉效果那么簡單,只有Shader的修改就可以了。這里需要額外處理的部分有如下幾點:
1. 逐個縮放的效果需要控制“自動尋路中...”這句話中的每一個字,因此在這個效果中每一個字都是一個Text控件。正在縮放的字使用特殊的縮放材質來繪制,其他的字依然使用默認的UI材質繪制,這意味著需要2個DrawCall來實現整體效果。
2. vert函數中需要獲取字體的中心點和長寬大小,然后進行縮放計算,也就是參數_VertexParmas的內容。經過測試,在ugui中,頂點的位置信息worldPosition是其相對于Canvas的位置信息,因此這里需要在C#中進行計算,計算過程借助RectTransformUtility的ScreenPointToLocalPointInRectangle函數:
Vector2tempPos;RectTransformUtility.ScreenPointToLocalPointInRectangle(ParentCanvas.transformasRectTransform,ParentCanvas.worldCamera.WorldToScreenPoint(img.transform.position),ParentCanvas.worldCamera,outtempPos);
這里的ParentCanvas是當前控件向上遍歷找到的第一個Canvas對象。
Tips:這里,搞清楚了vs中頂點的worldPosition對應的屬性之后,可以做很多有趣的事情,包括之前的旋轉效果也可以不再旋轉uv而是對頂點位置進行旋轉。
3. 關于_Time,它的y值與C#中對應的是Time.timeSinceLevelLoad。這里為了實現界面一開始的時候第一個字是從0開始放大的,需要從C#傳遞給Shader一個起始時間,通過_Time.y - _TimeOffset來確保起始效果。
最后直接貼一下C#部分的代碼好了,也很簡單,提供給UI配置縮放尺寸、縮放持續時間和間隔等參數,然后通過協程來控制字體的材質參數:
usingSystem;usingSystem.Collections.Generic;usingUnityEngine;usingUnityEngine.EventSystems;usingUnityEngine.UI;usingDG.Tweening;usingThorUtils;usingSystem.Collections;namespaceKEngine.UI{publicclassUIAutoExpand:MonoBehaviour{publicfloatScaleDuration=1;publicfloatCycleInterval=5;publicfloatScaleRatio=0.5f;privateCanvasParentCanvas;privateImage[]images;privateVector4[]meshSizes;privateintcurrentScaleIdx=-1;privateCoroutineplayingCoroutine;privatestaticMaterialUIExpandMat=null;privatestaticintVertexParamsID=-1;privatestaticintTimeOffsetID=-1;privatestaticintTimeScaleID=-1;privatestaticintScaleRatioID=-1;voidAwake(){//初始化靜態變量if(UIExpandMat==null){UIExpandMat=newMaterial(Shader.Find("ThorShader/UI/UIExpand"));VertexParamsID=Shader.PropertyToID("_VertexParmas");TimeOffsetID=Shader.PropertyToID("_TimeOffset");TimeScaleID=Shader.PropertyToID("_TimeScale");ScaleRatioID=Shader.PropertyToID("_ScaleRatio");}UIExpandMat.SetFloat(TimeScaleID,1/ScaleDuration);UIExpandMat.SetFloat(ScaleRatioID,ScaleRatio);if(ParentCanvas==null){Transformtrans=transform;while(trans!=null){ParentCanvas=trans.GetComponent
Tips:這里使用了Shader.PropertyToID方法來減少給material賦值過程中的消耗,對于攜程使用了一個Yielders類減少頻繁的內存分配。
總之,基于Shader來對持續的DoTween動畫進行優化,可以大大減少Canvas重建的幾率。而Shader中基于頂點和_Time屬性進行動畫計算的消耗非常少,比如通常的Image只有四個頂點而已,再配合部分C#代碼提供給材質必須的參數,就可以實現更加復雜的ui動畫。尤其對于會長時間存在的動畫效果,如果可以善用Shader可以做到兼顧效果和效率。
6. 進度條
在進行戰斗中的Profile的時候也是發現了每幀都有一個Canvas重建的過程,排查后發現是用于顯示倒計時效果的進度條在持續地被更新導致的。
UGUI的進度條控件功能非常通用,但是層次很復雜,包括Background、Fill Area和Handle Slide Area三個部分。它是實現原理是基于Mesh的修改:
從上面的gif可以看出,當Slider的value更改的時候,mesh會跟著調整。這可以做到一些UI想要的效果,比如讓Fill中的圖是一張九宮格的形式,就可以做出比較好看的進度條效果,保證拉伸之后的效果是正確的。
當你需要一條可能持續變化的進度條一直在顯示的時候,比如倒計時進度,持續的Canvas重建就不可避免。
針對具體的需求,通過Shader來進行一個簡單的ProgressBar也非常容易,通過對于alpha的控制就可以做到截取的效果:
fixed4frag(v2fIN):SV_Target{half4color=(tex2D(_MainTex,IN.texcoord)+_TextureSampleAdd)*IN.color;#if _ISVERTICAL_ONfloatuvValue=IN.texcoord.y-_UVRect.y;floattotalValue=_UVRect.w;#elsefloatuvValue=IN.texcoord.x-_UVRect.x;floattotalValue=_UVRect.z;#endif#if _ISREVERSE_ONuvValue=totalValue-uvValue;#endifcolor.a*=uvValue/totalValue<_Progress;color.a*=UnityGet2DClipping(IN.worldPosition.xy,_ClipRect);#ifdef UNITY_UI_ALPHACLIPclip(color.a-0.001);#endifreturncolor;}
這次是在ps階段進行處理,當然也可以在vs中模擬頂點的縮放效果或者處理uv的偏移。為了支持垂直和反向,這里通過兩個宏來進行控制。在實現了條狀的進度條,然后準備根據UI的具體需求進行效果上優化的時候,UI同學表示設計方案修改了變成了圓形的進度條(=_=),而且是兩個方向同時展示進度,最終效果如下圖所示的效果:
不要被酷炫的特效晃瞎眼。。。重點是紅色的倒計時進度條部分。在ps中進行位置的計算,然后通過alpha的值同樣可以達到控制進度的效果:
fixed4frag(v2fIN):SV_Target{half4color=(tex2D(_MainTex,IN.texcoord)+_TextureSampleAdd)*IN.color;floattheta=atan2((IN.texcoord.y-_UVRect.y)/_UVRect.w-0.5,(IN.texcoord.x+1E-18-_UVRect.x)/_UVRect.z-0.5);#ifdef IS_SYMMETRYcolor.a*=((1-_Progress)*UNITY_PI
這里用了一個消耗比較大的atan2指令來進行弧度值的計算,支持對稱和非對稱的兩種方式,對稱的方式用于上面的特殊進度條,非對稱的方式用于下面這種環形的進度條。
UGUI針對Image提供了Filled的Image Type來做環形進度條的效果,其原理是根據角度來更改Mesh實現的。
為了兼容Image中使用的Atlas,這里需要將uv信息設置給材質:
/// /// 更新貼圖的uv值到材質中/// 注意:需要在Image更新的時候調用本邏輯/// publicvoidUpdateImageUV(){if(relativeImage!=null){Vector4uv=(relativeImage.overrideSprite!=null)?DataUtility.GetOuterUV(relativeImage.overrideSprite):defaultUV;uv.z=uv.z-uv.x;uv.w=uv.w-uv.y;relativeImage.material.SetVector(UVRectId,uv);}}
對于進度條的修改,尤其是環形進度條,是在Shader的ps階段做的,因此消耗可能還比較大,和Mesh重建的過程的消耗我沒有做具體的對比,相當于拿GPU換取CPU的消耗,有可能在某些設備上還不夠劃算。這個具體使用哪種方法更好,或者是否需要繼續優化就看讀者自己具體的項目需求了。
7. 總結
針對UGUI的優化零零散散也做了不少,上面討論到的是其中影響相對大的部分,另外一大塊內容是在UGUI中使用特效,這塊和本次博客的主題關系不大就不放一起聊了。
可以看到,雖然從結果上看,這些優化后使用的Shader技術都非常非常簡單,大都是一些uv計算或者頂點位置的計算,相對于需要進行光照陰影等計算的3D Shader,UI中使用的Shader簡直連入門都算不上,但是通過合理地使用它,配合部分C#代碼邏輯,可以實現兼顧效果和效率的UI控件功能。
另外,雖然從結果看很簡單,仿佛每一個Shader都只需要1-2個小時就可以完成,但是在排查問題和思考優化方法的過程中其實也花費了很多精力,有很多的糾結和思考。這些方案的對比和思考的過程由于時間關系沒有全部反映在這篇博客里,但你從Tips和一些只言片語中也可以窺見到一些當時的心路歷程……除此之外,由于項目已經到了中后期,還有不少時間花費在新控件的易用性和向前兼容的方面,以讓UI和程序同學可以用盡量少的時間來完成對于之前資源的優化工作。
最后,我想坦誠地說,對于UGUI和基于Shader的方案,我沒有進行定量的性能對比測試,所以也不能保證基于Shader的方法都一定效率更高,比如最后圓形的Mask就可能會是一個反例。我能做到的是盡量公正地從原理角度分析兩者之間在Overdraw、Drawcall和Canvas重建方面的性能差異,也可能有考慮不全的地方,歡迎大家一起討論~
最后的Tips:除了一些“傻X”bug引發的“神級”優化之外,大部分的優化都是瑣碎而且成效不會那么直接、顯著的工作。比如上面這些內容可能花費了我大約2個周左右的時間,還需要推進UI和程序對于已有的資源進行修改,而且面臨著需求變更的問題……然而,面對優化工作還是那句話——“勿以惡小而為之,勿以善小而不為。”
另外,讓團隊中的每一個人都了解更多更深入的技術原理,擁有對于性能消耗的警覺,才不至于讓問題在最后Profile的時刻集中爆發出來,而是被消化在日常開發的點點滴滴之中,這也是我對于理想團隊的期(huan)待(xiang)。
2018年1月31日? 于杭州濱江海創基地(拖延癥總是最后一天才寫這個月的技術博客……)