如何用 GPU硬體層加速優化Android系統的遊戲流暢度

六一發表於2021-10-28

作為一款VR實時操作遊戲App,我們需要根據重力感應系統,實時監控手機的角度,並渲染出相應位置的VR影像,因此在不同 Android 裝置之間,由於使用的晶片組和不同架構的GPU,遊戲效能會因此受到影響。舉例來說:遊戲在 Galaxy S20+ 上可能以 60fps 的速度渲染,但它在HUAWEI P50 Pro上的表現可能與前者大相徑庭。 由於新版本的手機具有良好的配置,而遊戲需要考慮基於底層硬體的執行情況。

如果玩家遇到幀速率下降或載入時間變慢,他們很快就會對遊戲失去興趣。
如果遊戲耗盡電池電量或裝置過熱,我們也會流失處於長途旅行中的遊戲玩家。
如果提前預渲染不必要的遊戲素材,會大大增加遊戲的啟動時間,導致玩家失去耐心。
如果幀率和手機不能適配,在執行時會由於手機自我保護機制造成閃退,帶來極差的遊戲體驗。

基於此,我們需要對程式碼進行優化以適配市場上不同手機的不同幀率執行。

所遇到的挑戰

首先我們使用Streamline 獲取在 Android 裝置上執行的遊戲的配置檔案,在執行測試場景時將 CPU 和 GPU效能計數器活動視覺化,以準確瞭解裝置處理 CPU 和 GPU 工作負載,從而去定位幀速率下降的主要問題。

以下的幀率分析圖表顯示了應用程式如何隨時間執行。

在下面的圖中,我們可以看到執行引擎週期與 FPS 下降之間的相關性。顯然GPU 正忙於算術運算,並且著色器可能過於複雜。

為了測試在不同裝置中的幀率情況,使用友盟+U-APM測試不同機型上的卡頓狀況,發現在onSurfaceCreated函式中進行渲染時出現卡頓, 應證了前文的分析,可以確定GPU是在算數運算過程中發生了卡頓:

因為不同裝置有不同的效能預期,所以需要為每個裝置設定自己的效能預算。例如,已知裝置中 GPU 的最高頻率,並且提供目標幀速率,則可以計算每幀 GPU 成本的絕對限制。

數學公式: $ 每幀 GPU 成本 = GPU 最高頻率 / 目標幀率 $

CPU 到 GPU 的排程存在一定的約束,由於排程上存在限制所以我們無法達到目標幀率。

另外,由於 CPU-GPU 介面上的工作負載序列化,渲染過程是非同步進行的。
CPU 將新的渲染工作放入佇列,稍後由 GPU 處理。

資料資源問題

CPU 控制渲染過程並且實時提供最新的資料,例如每一幀的變換和燈光位置。然而,GPU 處理是非同步的。這意味著資料資源會被排隊的命令引用,並在命令流中停留一段時間。而程式中的OpenGL ES 需要渲染以反映進行繪製呼叫時資源的狀態,因此在引用它們的 GPU 工作負載完成之前無法修改資源。

除錯過程

我們曾做出嘗試,對引用資源進行程式碼上的編輯優化,然而當我們嘗試修改這部分內容時,會觸發該部分的新副本的建立。這將能夠一定程度上實現我們的目標,但是會產生大量的 CPU 開銷。

於是我們使用Streamline查明高 CPU 負載的例項。在圖形驅動程式內部libGLES_Mali.so路徑函式, 檢視中看到極高的佔用時間。

由於我們希望在不同手機上適配不同幀率執行,所以需要查明libGLES_Mali.so是否在不同機型的裝置上都產生了極高的佔用時間,此處採用了友盟+U-APM來檢測使用者在不同機型上的函式佔用比例。

友盟+ U-APM自定義異常測試,下列機型會產生高libGLES_Mali.so佔用的問題,因此我們需要基於底層硬體的執行情況來解決流暢性問題,同時由於存在問題的機型不止一種,我們需要從記憶體層面著手,考慮如何呼叫較少的記憶體快取區並及時釋放記憶體。

解決方案及優化

基於前文的分析,我們首先嚐試從緩衝區入手進行優化。

單緩衝區方案
• 使用glMapBufferRange和GL_MAP_UNSYNCHRONIZED.然後使用單個緩衝區內的子區域構建旋轉。這避免了對多個緩衝區的需求,但是這一方案仍然存在一些問題,我們仍需要處理管理子區域依賴項,這一部分的程式碼給我們帶來了額外的工作量。

多緩衝區方案
• 我們嘗試在系統中建立多個緩衝區,並以迴圈方式使用緩衝區。通過計算我們得到了適合的緩衝區的數目,在之後的幀中,程式碼可以去重新使用這些迴圈緩衝區。由於我們使用了大量的迴圈緩衝區,那麼大量的日誌記錄和資料庫寫入是非常有必要的。但是有幾個因素會導致此處的效能不佳:

1. 產生了額外的記憶體使用和GC壓力
2. Android 作業系統實際上是將日誌訊息寫入日誌而並非檔案,這需要額外的時間。
3. 如果只有一次呼叫,那麼這裡的效能消耗微乎其微。但是由於使用了迴圈緩衝區,所以這裡需要用到多次呼叫。
我們會在基於c#中的 Mono 分析器中啟用記憶體分配跟蹤函式用於定位問題:

$ adb shell setprop debug.mono.profile log:calls,alloc

我們可以看到該方法在每次呼叫時都花費時間:

Method call summary Total(ms) Self(ms) Calls Method name 782 5 100 MyApp.MainActivity:Log (string,object[]) 775 3 100 Android.Util.Log:Debug (string,string,object[]) 634 10 100 Android.Util.Log:Debug (string,string)

在這裡定位到我們的日誌記錄花費了大量時間,我們的下一步方向可能需要改進單個呼叫,或者尋求全新的解決方案。

log:alloc還讓我們看到記憶體分配;日誌呼叫直接導致了大量的不合理記憶體分配:

Allocation summary Bytes Count Average Type name 41784 839 49 System.String 4280 144 29 System.Object[]

硬體加速

最後嘗試引入硬體加速,獲得了一個新的繪圖模型來將應用程式渲染到螢幕上。它引入了 DisplayList 結構並且記錄檢視的繪圖命令以加快渲染速度。

同時,可以將 View 渲染到螢幕外緩衝區並隨心所欲地修改它而不用擔心被引用的問題。此功能主要適用於動畫,非常適合解決我們的幀率問題,可以更快地為複雜的檢視設定動畫。

如果沒有圖層,在更改動畫屬性後,動畫檢視將使其無效。對於複雜的檢視,這種失效會傳播到所有的子檢視,它們反過來會重繪自己。

在使用由硬體支援的檢視層後,GPU 會為檢視建立紋理。因此我們可以在我們的螢幕上為複雜的檢視設定動畫,並且使動畫更加流暢。

程式碼示例:

// Using the Object animator view.setLayerType(View.LAYER_TYPE_HARDWARE, null); ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(view, View.TRANSLATION_X, 20f); objectAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { view.setLayerType(View.LAYER_TYPE_NONE, null); } }); objectAnimator.start(); // Using the Property animator view.animate().translationX(20f).withLayer().start();

另外還有幾點在使用硬體層中仍需注意:

(1)在使用之後進行清理:

硬體層會佔用GPU上的空間。在上面的 ObjectAnimator程式碼中,偵聽器會在動畫結束時移除圖層。在 Property animator 示例中,withLayers() 方法會在開始時自動建立圖層並在動畫結束時將其刪除。

(2)需要將硬體層更新視覺化:

使用開發人員選項,可以啟用“顯示硬體層更新”。
如果在應用硬體層後更改檢視,它將使硬體層無效並將檢視重新渲染到該螢幕外緩衝區。

硬體加速優化

但是由此帶來了一個問題是,在不需要快速渲染的介面,比如滾動欄, 硬體層也會更快地渲染它們。當將 ViewPager 滾動到兩側時,它的頁面在整個滾動階段會以綠色突出顯示。

因此當我滾動 ViewPager 時,我使用 DDMS 執行 TraceView
,按名稱對方法呼叫進行排序,搜尋“android/view/View.setLayerType”,然後跟蹤它的引用:

ViewPager#enableLayers(): private void enableLayers(boolean enable) { final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final int layerType = enable ? ViewCompat.LAYER_TYPE_HARDWARE : ViewCompat.LAYER_TYPE_NONE; ViewCompat.setLayerType(getChildAt(i), layerType, null); } }

該方法負責為 ViewPager 的孩子啟用/禁用硬體層。它從 ViewPaper#setScrollState() 呼叫一次:

private void setScrollState(int newState) { if (mScrollState == newState) { return; } mScrollState = newState; if (mPageTransformer != null) { enableLayers(newState != SCROLL_STATE_IDLE); } if (mOnPageChangeListener != null) { mOnPageChangeListener.onPageScrollStateChanged(newState); } }

正如程式碼中所示,當滾動狀態為 IDLE 時硬體被禁用,否則在 DRAGGING SETTLING 時啟用。PageTransformer 旨在“使用動畫屬性將自定義轉換應用於頁面檢視”(Source)。

基於我們的需求,只在渲染動畫的時候啟用硬體層,所以我想覆蓋ViewPager 方法,但由於它們是私有的,我們無法修改這個方法。

所以我採取了另外的解決方案:在 ViewPage#setScrollState() 上,在呼叫 enableLayers() 之後,我們還會呼叫

OnPageChangeListener#onPageScrollStateChanged()

。所以我設定了一個監聽器,當 ViewPager 的滾動狀態不同於 IDLE 時,它將所有 ViewPager 的孩子的圖層型別重置為 NONE

@Override public void onPageScrollStateChanged(int scrollState) { // A small hack to remove the HW layer that the viewpager add to each page when scrolling. if (scrollState != ViewPager.SCROLL_STATE_IDLE) { final int childCount = <your_viewpager>.getChildCount(); for (int i = 0; i < childCount; i++) <your_viewpager>.getChildAt(i).setLayerType(View.LAYER_TYPE_NONE, null); } }

這樣,在 ViewPager#setScrollState() 為頁面設定了一個硬體層之後——我將它們重新設定為NONE,這將禁用硬體層,因此而導致的幀率區別主要顯示在 Nexus上。

相關文章