剖析虛幻渲染體系(12)- 移動端專題Part 3(渲染優化)

 

 

12.6 移動端渲染優化

前面幾章詳盡地剖析了移動端GPU架構的特性和機理,那麼就可以指導我們抽象出一些準則,從而獲得高性能的渲染程式碼和應用程式。

為了獲得流暢、高效、良好體驗,每個應用程式都必須重視性能優化,並貫穿始終。應用程式的性能優化分為以下三角循環:

第一步,分析應用程式的整體性能。

第二步,利用工具定位出性能瓶頸。

第三步,修改應用程式。回到第一步遞歸分析。

這個三角循環什麼時候停止呢?那就是應用程式的性能已經達到了項目之初指定的標準(如高中低畫質不低於多少幀,DC、三角面數小於多少等),並且已經知道應用程式已經達到了效率極限,再往下便到了投入產出比很小的牛角尖。

本篇會涉及以下概念:

名稱 別名 描述
USC (Unified Shading Cluster) Shading Cluster, Shading Unit, Execution Unit 圖形核心的半自主部分,通常可以執行整個工作組。其他大型部件如紋理單位(Texture Unit)可以在USC之間共享。
Core Processor, Graphics Core 圖形核心的一個幾乎完全自主的部分。通常情況下,是USC的集合以及可能支援的硬體,如紋理單元。
Task Thread Group, Warp, Wavefront USC執行的執行緒的原生分組,PowerVR Rogue內核由32個執行緒組成。
shared Shared variables 存儲於Shared memory的變數。
const / uniform const / uniform變數, uniform塊, uniform緩衝區 存儲於Constant memory的變數、塊、緩衝區。

補充一下PowerVR Rogue硬體架構和數據流交互圖,如下所示:

PowerVR Rogue的Unified Shading Cluster(USC)如下所示:

另外,補充一下本章大量涉及的片元(fragment)的概念:

片元(fragment)是GPU內部的幾何體光柵化後形成的最小表示單元,它經過一系列片元操作(alpha測試,深度測試,模板測試等)後,才可能最終寫入渲染紋理成為像素(pixel)。所以,片元不是像素,但有概率成為像素。

不過在D3D或UE內部,沒有片元的概念,像素包含了片元。

12.6.1 渲染管線優化

12.6.1.1 使用新特性

  • Variable Rate Shading

Variable Rate Shading(VRS,可變率著色)允許像素著色器一次著色一個或多個像素,這樣一個著色計算可以代表一個像素或一組像素。VRS是反鋸齒技術的逆解。抗鋸齒技術通過平滑高變化的內容,更頻繁地取樣每個像素,以避免走樣(aliasing)和鋸齒(jagged)邊緣。然而,如果要渲染的表面沒有高的顏色變化或將在隨後的通道上被模糊(例如,運動模糊),在每個像素都一個著色計算的操作通常是低效的。

VRS允許開發者指定著色率,其中只對一個像素執行一個著色器計算,結果操作應用於指定的像素組配置。如果使用得當,應該不會導致視覺品質下降,同時顯著減輕GPU渲染的負擔,從而節省功耗並提高性能。

VRS示意圖。畫面根據顏色變化頻率採用不同的著色率,變化高的採用高著色率(如汽車),反之用低著色率(如左下和右下路面)。

VRS支援的常見著色率和運行機制。其中黃點是著色坐標,綠點是直接復用黃點的著色結果。

VRS在渲染管線的工作機制。VRS在光柵化階段採用指定著色率執行光柵化,進入PS之後再放大。

UE可以給每個材質設定1個著色率,在材質屬性模板中:

VRS優化的核心思想在於減少計算次數並復用周邊計算點的結果,從而達到提升渲染效率的目的。適合使用VRS的情形:

  • 顏色變化率低的物體。
  • 處於運動模糊區域的物體。
  • 景深範圍之外的物體。

使用移動端的VRS需要依賴不同圖形API的擴展:

// ------ OpenGLES ------
// Qualcomm 
QCOM_shading_rate
GL_SHADING_RATE_1X1_PIXELS_QCOM
GL_SHADING_RATE_1X2_PIXELS_QCOM
......

// Arm / Imagination Tech
(不支援)

// ------ Vulkan ------
VK_KHR_fragment_shading_rate
  • 使用Vulkan代替OpenGL。

相比OpenGL等傳統API,Vulkan支援多執行緒,輕量化驅動層,可以精確地管控GPU記憶體、同步等資源,避免運行時校驗,基於命令隊列的機制,沒有全局狀態等等(下圖)。

得益於Vulkan的先進設計理念,使得它的渲染性能更高,通常在CPU、GPU、頻寬、能耗等指標都優於OpenGL。但如果是應用程式本身的CPU或者GPU負載高,則使用Vulkan的收益可能沒有那麼明顯:

  • 使用遮擋剔除。

遮擋剔除可以提前剔除掉被遮擋的物體或者遠處占螢幕很小的物體,避免進入GPU管線,佔用頻寬和計算資源。UE在移動端的遮擋剔除延遲了兩幀(因為BasePass結束之後才有深度緩衝,需要再增加一幀延遲確保結果可用):

然後是在RHI執行緒等待遮擋查詢的結果,遮擋查詢的結果是在渲染執行緒使用,由於延遲了兩幀,所以渲染執行緒在計算可見性時不需要等待:

使用遮擋剔除時,需要遵循以下建議:

1、只在需要時返回查詢結果,不要等待它,因為同步等待是非常低效的。

2、對於遮擋,只在必要時使用精確計數選項。OpenGL ES使用GL_ANY_SAMPLES_PASSED,、Vulkan使用VK_QUERY_CONTROL_PRECISE_BIT = false,除非確實需要知道遮擋的數量。

3、不要修改正在繪製調用中引用的資源。

4、不要將GL_MAP_INVALIDATE_BUFFER /GL_MAP_INVALIDATE_RANGE與glMapBufferRange()一起使用,因為這些標誌在某些版本的驅動會觸發創建一個不必要的資源拷貝。

12.6.1.2 管線優化

  • 曲面細分期間消除子像素。

曲面細分增加細節級別,並可以通過允許其他遊戲子系統在低解析度的網格表示上操作來減少記憶體頻寬和CPU周期。然而,高級別的曲面細分可以產生子像素三角形,這導致光柵化利用率降低。利用距離、螢幕空間大小或其他自適應度量來計算避免子像素三角形的曲面細分因子是很重要的。

  • 曲面細分期間開啟背面剔除。

圖元的背面剔除可以防止冗餘的像素進入像素著色器中,從而提升性能。

  • 刪除未使用的render target或shader資源。

操作更多的RT或shader資源,會增加頻寬,降低性能。故而盡量刪除未引用的資源。

  • 避免GMEM載入。

在每個Pass渲染之前,需要調用圖形API明確清理RT。

OpenGL ES: glClear()

Vulkan: LOAD_OP_CLEAR / LOAD_OP_DONT_CARE

  • 使用subpass或PLS。

Vulkan的subpass(或OpenGL ES的PLS)可以讓多個pass的數據持續保存在GMEM(Tile緩衝區)中,避免數據反覆從GMEM和全局記憶體之間傳輸,從而降低頻寬和延時。

  • 使用PSO快取。運行時創建PSO對象比較消耗CPU性能,如果在離線階段收集、編譯材質使用的Shader並保存成二進位文件,以便下次運行時調用時直接讀取Cache文件並轉成PSO對象,可以降低CPU負載。下圖是UE的PSO快取機製圖示:

更多詳情請參看UE官方文檔:PSO Caching

  • 使用僅深度(Z-only)渲染。

GPU有一種特殊的模式,可以以兩倍於正常模式的速率寫入Z-only像素,例如應用程式渲染陰影圖。

有兩種方式可以讓GPU進入此模式:

1、圖形API明確指示,硬體才能進入這個特殊的渲染模式。

2、應用程式通過特定的渲染狀態提示驅動程式。比如:使用一個空的片元著色器和禁用Frame Buffer(幀緩衝區)寫掩碼。

一些渲染程式或引擎(如UE)會使用專用的PrePass來渲染深度,以充分利用Early-Z計算。不過對於移動端GPU需要謹慎對待,應以實際測試為準。

  • 使用間接索引(indirect indexed)的繪製介面。

間接繪製調用將開銷從CPU轉移到GPU,從而減少CPU和GPU的頻寬。例如,在載入時快取繪製調用參數,以便在緩衝對象存儲中渲染網格。這些快取數據可以作為glDrawArraysIndirectglDrawElementsIndirect的輸入參數。

需要OpenGL ES 3.1才支援。

  • Draw Call優化。
    • 合併幾何物體,同時合併它們的材質。
    • 使用批處理,即便不是CPU受限,也可以減少能耗。
    • 使用實例化(instance)。
    • 使用非直接索引繪製。
    • 避免多次繪製小量物體。
    • 根據高中低畫質設定合理的Draw Call數量。

使用批處理時,要注意頂點總數限制,不能超過索引的表達範圍(通常最大是65k)。另外,如果合併或批處理之後的物體包圍盒過大,反而會造成性能下降,因為無法有效使用Frustum Cullinig、遮擋剔除等技術進行剔除。

另外,需要注意提交的幾何物體具有相鄰性,盡量落在同一個Tile內,以減少覆蓋的Tile數量,降低頻寬,提升快取命中率:

上:良好的幾何物體提交順序;下:錯誤的幾何物體提交順序。

  • 禁用Alpha Test / Discard。

Alpha Test會打亂TBR的正常流程,造成渲染管線Stall,在PowerVR尤為明顯(Alpha Test階段會寫回深度到HSR階段)。

因為TB(D)R在渲染不透明物體時普遍開啟了Early-Z技術和特殊的隱藏面消除技術(HSR、FPK),在此階段會開啟深度測試,並寫入通過了深度測試的片元深度。但是,如果開啟了Alpha Test或Shader中使用了Discard,無法在Early-Z/隱藏面消除技術階段就確定該片元的深度是否有效,必須等執行完PS、Alpha Test等階段才行:

這樣就無法充分發揮HSR技術的優勢,從而降低渲染性能。

可以使用Alpha Blend代替Alpha Test。如果確實需要Alpha Test,則物體的渲染順序需尊照此順序:Opaque -> Alpha-tested -> Blended。

  • 盡量減少Alpha Blend。

原因是延遲渲染器,比如PowerVR GPU,在片元著色器處理它之前計算片元的可見性,防止輸出影像中的不可見片元被不必要地處理。如果需要透明對象,請盡量減少透明對象的數量。

由於Alpha Blend不能寫入深度,不能充分利用HSR/FPK,會引發Overdraw,提升頻寬和數據傳輸量。

如果確實需要,有以下優化建議:

1、優先使用unorm格式,而不是浮點數。(注意:此條來自Arm Mali的建議,其它GPU可能不一樣,以實測為主)

2、如果是不透明物體,應禁用Blend和alpht to coverage。

3、不要在攜帶MSAA數據的浮點frame buffer上使用混合。

4、避免過高的OverDraw。監控每像素基礎上生成的混合層數量,即使是簡單的著色器,混合層數量高會因為片元數量多而快速消耗時鐘周期。

5、考慮將大型UI元素分成不透明和透明部分。然後可以分別繪製不透明部分和透明部分,允許Early-ZS或FPK/HSR刪除不透明部分下面的OverDraw。

6、不要僅僅在片元著色器中將alpha設置為1.0來禁用混合。

  • 充分利用Early-Z和FPK/HSR剔除被遮擋的像素。

為了充分利用Early-Z,物體繪製順序應該如下所示:

1、繪製不透明物體。從前向後繪製。

2、繪製鏤空(Masked)物體。從前向後繪製。

3、繪製半透明物體。從後向前繪製。

對於廣泛支援TBR架構的移動端GPU,不建議開啟Prepass繪製專用的深度,否則反而會增加頻寬和Draw Call。

另外,在繪製不透明物體時,盡量做到以下幾點:

1、禁用discard語句。

2、禁用Alpha to Coverage。

3、禁止在片元著色器中修改深度。

若是違反以上任意一條,便會使Early-Z失效,強制使用Late-Z,從而降低渲染效率。

  • 充分開啟裁剪和測試。

裁剪技術包含遮擋剔除、視錐體裁剪、Scissor、距離裁剪、LOD等等。

測試包含背面測試、深度測試、模板測試等,但禁用透明度測試。

  • 禁用Z-Prepass。

移動端GPU基於TBR結構通常內置了像素級的剔除,無需再專門繪製一次深度。UE在移動端默認禁用了Z-Prepass。

  • 最小化模板緩衝的更新。

1、如果值相同,則使用KEEP而不是REPLACE。

2、有些渲染器(如UE)使用光照繪製Pass對(pair):第一個Pass用於創建模板緩衝,第二個Pass用於給未蒙版的片元著色。可以在第二個Pass重置模板值,以便為下一個光照配對做好準備,這樣可以避免單獨的模板清理操作。

UE的移動端場景渲染器在繪製光照時正是使用了此種模板清理優化方式。

  • 正確調用圖形API。

    • 除非達到了目標性能,否則不要以導致GPU空閑的方式使用API。

    • 不要過早等待渲染管線中的圍欄(fence)和查詢(query)對象的查詢結果。

    • 調用glMapBufferRange()時使用GL_MAP_UNSYNCHRONIZED標記開啟非同步,防止渲染管線卡頓。

    • 避免同步方式調用以下介面:

      • glFlush()。但是,某些GPU(如PowerVR)由於使用了雙緩衝機制,不會卡調用執行緒。
      • glFinish()
      • glReadPixels()
      • glWaitSync()
      • glClientWaitSync()
      • eglClientWaitSync()
      • 沒有GL_MAP_UNSYNCHRONIZED標記的glMapBufferRange()

      避免不必要地調用以上介面,調用次數越少越好。

    • 避免使用glFlush()來分割渲染通道,因為驅動程式(Mali)會在需要時自動刷新。

    • 儘可能執行Clear。在繪製前或渲染通道開始時,使用glClear/glDiscardFramebufferEXT/glInvalidateFramebuffer執行渲染紋理的清理,防止GPU讀取上一幀的數據到Tile緩衝區中,節省頻寬。Vulkan則使用loadOp。

    • 儘可能使用glColorMask屏蔽不需要寫入的顏色通道。

如果違反以上建議,有可能導致以下結果:

1、如果管道被耗盡,GPU在產生氣泡期間部分空閑,導致性能損失。

2、根據與系統動態電壓和頻率縮放電源管理邏輯的相互作用,可能會有一些性能不穩定。

  • 優化Command Buffer。

1、要獲得最佳性能,請設置ONE_TIME_SUBMIT_BIT標誌。不要設置SIMULTANEOUS_USE_BIT,除非確實需要。

2、構建每幀命令緩衝區,而不是使用同步命令緩衝區。

3、如果替代方法是每次在應用程式邏輯中重放相同的命令序列,則使用SIMULTANEOUS_USE_BIT。它比應用程式手動重放命令更有效,但比一次性提交緩衝區更低效。

4、不要使用設置了RESET_COMMAND_BUFFER_BIT的命令池,會增加記憶體管理開銷,因為驅動程式無法為池中的所有命令緩衝區使用單個大型分配器。

5、使用secondary command buffer來允許多執行緒渲染通道的構造。

6、最小化每幀secondary command buffer的調用次數。

  • 優化描述符集和布局(descriptor sets and layouts)。

1、儘可能多地打包描述符集綁定空間。

2、更新已經分配但不再引用的描述符集,而不是重置描述符池和重新分配新的描述符集。

3、重用預分配的描述符集,避免更新相同的資訊。

4、使用VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER_DYNAMIC或VK_DESCRIPTOR_TYPE_STORAGE_BUFFER_DYNAMIC綁定相同的UBO或SSBO,但不同的偏移量。 另一種選擇是構建更多的描述符集。

5、不要在描述符集中留下空白,會浪費空間,阻斷訪問連續性。

6、不要留下未使用的條目(entry),因為複製和合併依舊有消耗。

7、不要在性能關鍵的程式碼路徑上從描述符池(descriptor pool)分配描述符集。

8、如果不打算更改綁定偏移量,就不要使用DYNAMIC_OFFSET UBOs/SSBOs,因為處理動態偏移量會有很小的額外成本。低效的描述符集和布局未優化的Vulkan描述符集和布局的負面影響可能會增加繪製調用的CPU消耗。

  • 避免渲染管線氣泡(空閑)。

以下幾種情況會產生渲染管線氣泡:

1、Command Buffer提交不夠頻繁。不經常提交命令緩衝區會減少GPU處理隊列中的工作量,限制潛在的編排機會。

2、數據依賴。假設有渲染通道M和N,M在稍後的階段。當N在管道中被M更早地使用時,數據依賴就產生了。數據依賴會導致延遲,在此期間必須做足夠的工作來隱藏結果生成中的延遲。

渲染管線氣泡示意圖。圖中顯示CPU、VS、PS都存在氣泡。

以下建議可以減少管線氣泡:

1、頻繁地提交Command Buffer。例如,為幀中的每個主要渲染通道之後,但渲染通道期間不宜提交。

2、如果某些情況導致了氣泡,嘗試填充氣泡技術。例如,通過在兩個渲染通道之間插入獨立的工作負載。

3、考慮在比使用依賴數據的階段更早的管道階段生成依賴數據。例如,計算(compute)階段適合為頂點著色階段生成輸入數據。而片元階段是不合適的,因為它的執行晚於頂點著色階段管道,否則會造成卡頓和延時。

4、考慮在管道中的更後階段處理依賴數據。例如,片元著色使用來自其他片元著色的輸出比計算著色使用片元著色更好。

5、使用柵欄非同步地將GPU的數據讀回CPU。千萬不用同步地調用從GPU讀取數據到CPU的介面,否則整個渲染管線將可能發生嚴重停滯。

此外,以下建議可以優化渲染管線:

1、不要在管道的任何地方不必要地等待GPU數據。

2、不要等到幀結束才提交所有的渲染通道。

3、在沒有足夠的中間工作來隱藏延遲的情況下,不要在管道中創建任何逆向(backwards)的數據依賴。

4、不要使用vkQueueWaitIdle()或vkDeviceWaitIdle()。

  • 正確使用管線同步。

現代圖形API(如Vulkan)擁有非常細粒度的管線階段:

typedef enum VkPipelineStageFlagBits
{
    VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT = 0x00000001,
    
    // Vertex Stages
    VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT = 0x00000002,
    VK_PIPELINE_STAGE_VERTEX_INPUT_BIT = 0x00000004,
    VK_PIPELINE_STAGE_VERTEX_SHADER_BIT = 0x00000008,
    VK_PIPELINE_STAGE_TESSELLATION_CONTROL_SHADER_BIT = 0x00000010,
    VK_PIPELINE_STAGE_TESSELLATION_EVALUATION_SHADER_BIT = 0x00000020,
    VK_PIPELINE_STAGE_GEOMETRY_SHADER_BIT = 0x00000040,
    // Fragment Stages
    VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT = 0x00000080,
    VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT = 0x00000100,
    VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT = 0x00000200,
    VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT = 0x00000400,
    // Compute Stages
    VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT = 0x00000800,
    VK_PIPELINE_STAGE_TRANSFER_BIT = 0x00001000,
    VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT = 0x00002000,
    
    VK_PIPELINE_STAGE_HOST_BIT = 0x00004000,
    VK_PIPELINE_STAGE_ALL_GRAPHICS_BIT = 0x00008000,
    VK_PIPELINE_STAGE_ALL_COMMANDS_BIT = 0x00010000,
    
    (......)
} VkPipelineStageFlagBits;

現代圖形API(如Vulkan)也包含了眾多同步對象:

1、Subpass依賴、Pipeline Barrier、Event等,用於單個Queue內的精細粒度同步。

2、Semaphore(訊號)用於跨Queue的較重度的依賴關係。

管線依賴存在兩個變數:srcStagedstStagesrcStage標明必須等待的管線階段(pipeline stage),dstStage標明在處理開始之前必須等待同步的管線階段。

為了更好的並行效率和更少的管線氣泡,srcStage越早越好,而dstStage越遲越好。如果srcStageVK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT時,將獲得最差的性能。

Semaphore可以使用pWaitDstStages指定具體的階段。

更具體地說,遵循以下準則,可以獲得更好的渲染效率:

1、srcStageMask被設置得越早越好。

2、dstStageMask被設置得越晚越好。

3、檢查依賴關係是向前的(比如srcStageMask是頂點或計算,dstStageMask是片元)還是向後的(如srcStageMask是片元,dstStageMask是頂點或計算)。 盡量減少使用向後依賴關係。

4、如果確實需要向後依賴,則在生成和消費資源之間添加足夠的延遲,以便隱藏向後依賴引起的調度氣泡。

5、使用srcStageMask = ALL_GRAPHICS_BIT 和 dstStageMask = FRAGMENT_SHADER_BIT 彼此同步兩個渲染通道。

6、零拷貝(Zero-copy)演算法是最有效的,因此盡量減少TRANSFER拷貝操作的使用。密切關注TRANSFER副本對硬體流水線的影響。

7、只在需要時使用隊列內屏障(intra-queue barrier),並在屏障之間儘可能多地安排工作。

8、不要讓硬體處於空閑狀態。

9、不要忘記重疊頂點/計算和片元之間的處理。

10、不要使用下面的srcStageMask到dstStageMask同步組合,因為它們會完全耗儘管道:

BOTTOM_OF_PIPE_BIT to TOP_OF_PIPE_BIT
ALL_GRAPHICS_BIT to ALL_GRAPHICS_BIT
ALL_COMMANDS_BIT to ALL_COMMANDS_BIT

11、如果合併管道屏障,請注意不要引入錯誤的依賴項。確保不打破頂點/片元重疊,並創建一個不必要的氣泡。

12、不要使用VkEvent訊號並立即等待該事件,用vkCmdPipelineBarrier()。

13、不要在單個Queue中使用VkSemaphore進行依賴管理。

14、不要讓渲染管線留有太大的空閑(否則降低性能),也不要讓渲染管線留有太小的空閑(否則可能產生錯誤)。

  • 正確處理管線資源。

OpenGL ES為應用開發人員提供了一個同步呈現模型,即使底層的執行可能是非同步的,必須反映數據資源在繪製調用時的狀態。如果一個應用程式修改了一個資源,而一個掛起的draw調用仍在引用它,那麼驅動程式必須採取規避操作來確保正確性。

驅動程式處理這些資源的同步行為時因GPU廠商而異,例如Mali驅動程式避免了阻塞和等待資源引用計數達到零,因為這樣做會耗儘管道並導致性能低下。Mali GPU會創建一個全新版本的資源,資源的舊版本或幽靈(Ghost)版本將一直保留,直到掛起的繪製調用完成,其引用計數降至零。其它一些驅動程式(如PowerVR)會卡住本幀的渲染管線,延遲到下一幀處理,引發性能下降。

這種行為開銷大,需要為新資源分配記憶體,並在完成時清理空資源。如果更新不是完全替換,還需要從舊的資源緩衝區複製到新的資源緩衝區。

為了優化資源,需要遵循以下建議:

1、避免修改已入隊的draw call引用的資源,可以使用N-buffered資源,並通過管道進行動態資源更新。

2、使用GL_MAP_UNSYNCHRONIZED標記,以允許使用glMapBufferRange()來補齊緩衝區中仍被動態繪製調用引用的未引用區域。不要將GL_MAP_INVALIDATE_BUFFER /GL_MAP_INVALIDATE_RANGE與glMapBufferRange()一起使用,因為這些標誌在某些版本的驅動會觸發創建一個不必要的資源拷貝。

  • 高效地上傳紋理資源。

上傳紋理資源到到圖形硬體時,對於非壓縮紋理,按線性的掃描線上傳,對於壓縮的紋理,將會逐塊上傳。

部分GPU內部(如PowerVR)使用獨特的布局來改善記憶體訪問局部性和提高快取效率。數據的重新格式化是由專用硬體在晶片上完成的,因此非常快。如果能遵循以下步驟更能提升性能:

1、在非性能關鍵時期上傳紋理,如初始化。有助於避免與紋理載入相關的幀率下降。

2、避免上傳幀期間(mid-frame)的紋理數據到已經用於該幀的紋理對象。

3、在紋理上傳完成後執行一個預熱(warm-up)步驟。依然是有助於避免與紋理載入相關的幀率下降。

前面提到的預熱(warm-up)步驟可以確保紋理立即完全上傳。默認情況下,glTexImage2D不會立即執行上傳所需的所有處理,紋理是在第一次使用時完全上傳的。可以通過在螢幕上畫出一系列三角形或用有問題的紋理對象進行綁定處理來強制上傳。

12.6.1.3 頻寬優化

  • 注意數據的存放位置。如:RAM、VRAM、Tile Buffer、GPU Cache,減少不必要的數據傳輸。
  • 關注數據的訪問類型。如:是只讀還是只寫操作,是否需要原子操作,是否需要快取一致性。
  • 關注快取數據的可行性,硬體可以快取數據以供GPU後續操作快速訪問。可以通過以下幾點提升快取命中率:
    • 提高傳輸速度,確保客戶端頂點數據緩衝區被用於盡可能少的繪製調用。理想情況下,應用程式永遠不應該使用它們。
    • 減少GPU在執行調度或繪製調用時需要訪問的數據量。這樣可以讓盡量多的數據放到快取行,提升命中率。
  • 使用紋理壓縮格式。優先ASTC,其次是ETC、PVRTC、BC等壓縮格式。GPU的硬體通常都支援這類壓縮格式,可以快速地編解碼它們,並且可以一次性讀取更多的紋素內容到GPU的快取行,提升快取命中率。
  • 使用位數更少的像素格式。如RGB565比RGB888少8位,ASTC_6X6代替ASTC_4x4等。Adreno支援的像素格式參見Spec Sheets
  • 使用半精度(如FP16)取代高精度(FP32)數據。如模型頂點和索引數據,並且可以使用SOA(Structure of Array)數據布局,而不用AOS。
  • 降解析度渲染,後期再放大。可以減少頻寬、計算量,減少設備熱發熱量。
  • 盡量減少繪製次數。繪製數量的減少可以減少CPU和GPU之間、GPU內部的頻寬和消耗。
  • 確保數據存儲在On-Chip內。

利用PLS、Subpass的特性,可以實現移動端的延遲渲染、粒子軟混合等。下表是PowerVR GX6250在實現延遲渲染時,使用不同的位數和性能的關係:

配置 時間/幀(ms)
96bit + D32 20
128bit + D32 21
160bit + D32 23
192bit + D32 24
224bit + D32 28
256bit + D32 29
288bit + D32 39

以上可知,當位數大於256,超過GX6250的最大位數,數據無法完全存儲在On-Chip內,會外溢到全局記憶體,導致每幀時間暴增10ms,增幅為34.5%。

因此,對每像素的數據進行精心的組裝、優化和壓縮,保持數據能夠完全容納於On-Chip內,可有效提升性能,節省頻寬。

  • 避免多餘的副本。

確保使用相同記憶體的硬體組件(CPU、圖形核心、攝像機介面和影片解碼器等)都訪問相同的數據,而不需要進行任何中間複製。

  • 使用正確的標記創建Buffer、紋理等記憶體。部分Mali GPU(如Bifrost)執行以下幾個標記組合:

1、DEVICE_LOCAL_BIT | HOST_VISIBLE_BIT | HOST_COHERENT_BIT

2、DEVICE_LOCAL_BIT | HOST_VISIBLE_BIT | HOST_CACHED_BIT

3、DEVICE_LOCAL_BIT | HOST_VISIBLE_BIT | HOST_COHERENT_BIT | HOST_CACHED_BIT

4、DEVICE_LOCAL_BIT | LAZILY_ALLOCATED_BIT

其中HOST_VISIBLE_BIT | HOST_COHERENT_BIT | HOST_CACHED_BIT的記憶體類型說明如下:

1、提供CPU上的快取存儲,與記憶體的GPU視圖一致,無需手動同步。

2、如果晶片組支援CPU與GPU之間的硬體一致性協議,則該GPU支援此標記組合。

3、由於硬體的一致性,它避免了手動同步操作的開銷。當可用時,快取的、一致的記憶體優先於快取的、不一致的記憶體類型。

4、必須用於CPU上的應用軟體映射和讀取的資源。

5、硬體一致性的功耗很小,所以不能用於CPU上只寫的資源。對於只寫資源,通過使用Not Cached,一致記憶體類型繞過CPU快取。

關於LAZILY_ALLOCATED記憶體類型說明:

1、是一種特殊的記憶體類型,最初只支援GPU虛擬地址空間,而不是物理記憶體頁面。如果訪問記憶體,則根據需要分配物理頁。

2、必須與使用VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT創建的瞬態attachment一起使用。瞬態Image的目的是用作幀緩衝attachment,只存在於一個單一的渲染過程中,可以避免使用物理記憶體。

3、不能將數據寫回全局記憶體。

以下是Vulkan記憶體標記的使用建議:

1、對於不可變資源,使用HOST_VISIBLE | HOST_COHERENT記憶體。

2、對於CPU上只寫的資源,使用HOST_VISIBLE | HOST_COHERENT記憶體。

3、使用memcpy()將更新寫入HOST_VISIBLE | HOST_COHERENT記憶體,或者按順序寫入以獲得CPU write-combine單元的最佳效率。

4、使用HOST_VISIBLE | HOST_COHERENT | HOST_CACHED記憶體用於將資源讀回CPU,如果此組合不可以,則使用HOST_VISIBLE | HOST_CACHED。

5、使用LAZILY_ALLOCATED記憶體用於僅在單個渲染過程中存在的臨時幀緩衝區附件。

6、只將LAZILY_ALLOCATED記憶體用於TRANSIENT_ATTACHMENT幀緩衝區附件。

7、映射和取消映射緩衝區消耗CPU性能。因此要持久地映射經常被訪問的緩衝區,例如:統一緩衝區、數據緩衝區或動態頂點數據緩衝區。

  • 盡量使用零拷貝(Zero-Copy)路徑。

如下圖所示,通過使用EglImage實現Camera和OpenCL共享Original Image Data,OpenCL和OpenGL ES共享Final Image Data,從而達到零拷貝:

  • 將記憶體訪問分組。

編譯器使用幾種啟發式方法,可以識別內核中的記憶體訪問模式,這些模式可以組合成讀或寫操作的突發傳輸。為了讓編譯器更好實現這種優化,記憶體訪問應該儘可能緊密地組合在一起。

例如,將讀放在內核的開頭,寫放在內核的結尾,可以獲得最佳的效率。對更大的數據類型(如向量)的訪問也會儘可能地編譯為單個傳輸,載入1個float4比載入4個單獨的float值更好。

  • 合理使用Shared/Local記憶體。

可以在Shader初期(如初始化),將常訪問的數據先讀取到Shared/Local記憶體,提升訪問速度。

  • 以行優先(Row-Major)的順序訪問記憶體。

GPU通常會預讀取行相鄰的數據到GPU快取中,如果著色器演算法以行優先的方式訪問,可以提升Cache命中率,降低頻寬。

  • GPU特定頻寬優化。

Mali的Transaction elimination只有在以下情形適用:

1、取樣數據為1。

2、mimap級別為1。

3、image使用了COLOR_ATTACHMENT_BIT。

4、image沒有使用TRANSIENT_ATTACHMENT_BIT。

5、使用單一顏色附件。(Mali-G51 GPU及之後沒有此限制)

6、有效的tile尺寸是16×16像素,像素數據存儲決定了有效的tile尺寸。

Mali GPU還支援AFBC紋理,可以減少顯示記憶體和頻寬。

12.6.2 資源優化

12.6.2.1 紋理優化

  • 使用壓縮格式。

ASTC由於出色的壓縮率,更接近原圖的畫質,適應更多平台而成為首選的紋理壓縮格式。因此,只要可能,盡量使用ASTC。除非部分古老的設備,無法支援ASTC,才考慮使用ETC、PVRTC等紋理壓縮格式。詳見12.4.14 Adaptive Scalable Texture Compression

  • 盡量使用Mipmaps。

紋理Mipmaps提供提升記憶體佔用來達到降低取樣紋理時的數據量,從而降低頻寬,提升緩衝命中率,同時還能提升畫質效果。魚和熊掌皆可得,何樂而不為?具體地說表現在以下方面:

1、極大地提高紋理快取效率來提高圖形渲染性能,特別是在強烈縮小的情況下,紋理數據更有可能裝在Tile Memory。

2、通過減少不使用mipmapping的紋理取樣不足而引起的走樣來提高影像品質。

但是,使用Mipmaps會提升33%的記憶體佔用。以下情況需要避免使用:

1、過濾不能被合理地應用,例如對於包含非影像數據的紋理(索引或深度紋理)。

2、永遠不會縮小的紋理,比如UI元素,其中texel總是一對一地映射到像素。

  • 使用打包的圖集。

打包圖集之後,有可能合批渲染或實例化渲染,減少CPU和GPU的頻寬。

  • 尺寸保持2的N次方。

儘管目前的圖形API都已經支援非2N的次方尺寸(NPOT)的紋理,但有充分的理由建議保持紋理尺寸在2的N次方(POT):

1、在大多數情況下,POT紋理應該比NPOT紋理更受青睞,因為這為硬體和驅動程式的優化工作提供了最好的機會。(例如紋理壓縮、Mimaps生成、快取行對齊等)

2、2D應用程式應該不會因為使用NPOT紋理而出現性能損失(除非可能在上傳時)。2D應用程式可以是瀏覽器或其他呈現UI元素的應用程式,其中NPOT紋理以一對一的texel到pixel映射顯示。

3、保證長和寬都是32像素倍數的紋理,以便紋理上傳可以讓硬體優化。

  • 最小化紋理尺寸。
  • 最小化紋理位深。
  • 最小化紋理組件數量。
  • 利用紋理通道打包多張貼圖。例如將材質的粗糙度、高光度、金屬度、AO等貼圖打包到同一張紋理的RGBA通道上。

12.6.2.2 頂點優化

  • 使用分離位置的交錯的頂點布局。原因詳見12.4.11 Index-Driven Vertex Shading
  • 使用合適的頂點和索引存儲格式。降低數據精度可以降低記憶體、頻寬,提高計算單元運算量。目前主流移動端GPU支援的頂點格式有:
GL_BYTE
GL_UNSIGNED_BYTE
GL_SHORT
GL_UNSIGNED_SHORT
GL_FIXED
GL_FLOAT
GL_HALF_FLOAT
GL_INT_2_10_10_10_REV
GL_UNSIGNED_INT_2_10_10_10_REV
  • 考慮幾何物體實例化。現代移動端GPU普遍支援實例化渲染,通過提交少量的幾何數據可以繪製多次,來降低頻寬。每個實例允許擁有自己的數據,如顏色、變換矩陣、光照等。常用於樹、草、建築物、群兵等物體。

  • 圖元類型使用三角形。現代GPU設計便是處理三角形,如果是四邊形之類的很可能會降低效率。

  • 減少索引數組大小。如使用條帶(strip)格式代替簡單列表格式,使用原始的有效索引代替退化三角形。

  • 對於轉換後快取( post-transform cache),局部地優化索引。

  • 避免使用低空間一致性的索引緩衝區。會降低快取命中率。

  • 使用實例屬性來解決任何統一的緩衝區大小限制。 例如,16KB的統一緩衝區。

  • 每個實例使用2的N次方個頂點。

  • 優先使用gl_InstanceID到統一緩衝區或著色器存儲緩衝區的索引查找,而不是逐實例屬性數據。

12.6.2.3 網格優化

  • 使用LOD。

使用網格的LOD可以提升渲染性能和降低頻寬。相反,不使用LOD,會造成性能瓶頸。

同個網格不同LOD的線框模式。

以下是浪費計算和記憶體資源的例子:

1、使用大量多邊形的對象不會覆蓋螢幕上的一個小區域,比如一個遙遠的背景對象。

2、使用多邊形的細節,將永遠不會看到由於相機的角度或裁剪(如物體在視野錐之外)。

3、為對象使用大量的圖元。實際上可以用更少的圖元來繪製,還能保證視覺效果不損失。

  • 簡化模型,合併頂點。通過合併相鄰很近的頂點,可以有效減少網格頂點數量,利用網格簡化技術,可以生成良好的LOD數據。

  • 離線合併靠在一起的小網格。如沙石、植被等。

  • 單個網格的頂點數不能超過65k。主要是移動端的頂點索引精度是16位,最大值是65535。

  • 刪除看不見的圖元。例如箱子內部的三角形。

  • 使用簡單的幾何物體,配合法線貼圖、凹凸貼圖增加細節。

  • 避免小面積的三角形。

Quad的繪製機制,會導致小面積的三角形極大提升OverDraw。在PowerVR硬體上,對於覆蓋低於32個像素的三角形,會影響光柵化的效率,導致性能瓶頸。

提交許多小三角形可能會導致硬體在頂點階段花費大量時間處理它們,此階段主要影響因素是三角形的數量而不是大小。尤其會導致平鋪加速器( tile accelerator,TA)固定功能硬體的瓶頸。數量眾多的小三角形將導致對位於系統記憶體中的參數緩衝區(parameter buffer)的訪問次數增加,增加記憶體頻寬佔用。

  • 保證網格內每個圖元至少能創建10~20個像素。
  • 使用幾乎等邊的三角形。可以使面積與邊長的比例最大化,減少生成的片元Quad的數量。
  • 避免細長的三角形。

和小三角形類似,細長三角形(下圖紅色所示)也會產生更多無效的像素,佔用更高的GPU資源,提高Overdraw。

  • 避免使用扇形或類似的幾何布局。三角形扇形的中心點具有較高的三角形密度,以致每個三角形具有非常低的像素覆蓋率。可以考慮Tile軸對齊的切割,但會引入更多三角形。(下圖)

扇形(圖左)進行Tile軸對齊的切割後產生的三角形數量(圖右)。

12.6.3 Shader優化

12.6.3.1 語句優化

  • 使用適當的數據類型。

在程式碼中使用最合適的數據類型可以使編譯器和驅動程式優化程式碼,包括shader指令的配對。使用vec4數據類型而不是float可能會阻止編譯器執行優化。

int4 ResultOfA(int4 a) 
{
    return a + 1; // int4和int相加, 只需要1條指令.
}

int4 ResultOfA(int4 a) 
{
    return a + 1.0; // int4和float相加, 需要3條指令: int4 -> float4 -> 相加 -> int4
}
  • 減少類型轉換。
uniform sampler2D ColorTexture;
in vec2 TexC;
vec3 light(in vec3 amb, in vec3 diff)
{
    // 紋理取樣返回vec4, 會隱性轉換成vec3, 多出1條指令.
    vec3 Color = texture(ColorTexture, TexC); 
    Color *= diff + amb;
    return Color;
}

// 以下程式碼中, 輸入參數/臨時變數/返回值都是vec4, 沒有隱性類型轉換, 比上面程式碼少1條指令.
uniform sampler2D ColorTexture;
in vec2 TexC;
vec4 light(in vec4 amb, in vec4 diff)
{
    vec4 Color = texture(Color, TexC);
    Color *= diff + amb;
    return Color;
}
  • 打包標量常數。

將標量常數填充到由四個通道組成的向量中,大大提高了硬體獲取效率。在GPU骨骼動畫系統中,可增加蒙皮的骨骼數量。

float scale, bias;  // 兩個float值.
vec4 a = Pos * scale + bias; // 需要兩條指令.

vec2 scaleNbias; // 將兩個float值打包成一個vec2
vec4 a = Pos * scaleNbias.x + scaleNbias.y; // 一條指令(mad)完成.
  • 使用標量操作。

要小心標量操作向量化,因為相同的向量化輸出需要更多的時間周期。例如:

highp vec4 v1, v2;
highp float x, y;

// Bad!!
v2 = (v1 * x) * y; // vector*scalar接著vector*scalar總共8個標量muladd.
// Good!!
v2 = v1 * (x * y); // scalar*scalar接著vector*scalar總共5個標量muladd.

12.6.3.2 狀態優化

  • 盡量使用const。

如果正確使用,const關鍵字可以提供顯著的性能提升。例如,在main()塊之外聲明一個const數組的著色器比沒有的性能要好得多。

另一個例子是使用const值引用數組成員。如果值是const,GPU可以提前知道數字不會改變,並且數據可以在運行著色器之前被預讀取,從而降低Stall。

  • 保持著色器指令數量合理

過長的著色器通常比較低效,比如需要在一個著色器中包含相對於紋理獲取數量的許多指令槽,可以考慮將演算法分成幾個部分。

由演算法的一部分生成的值可以存儲到紋理中,然後通過取樣紋理來獲取。然而,這種方法在記憶體頻寬方面代價昂貴。以下情形也會降低紋理取樣效率:

1、使用三線性、各向異性過濾、寬紋理格式、3D和立方體貼圖紋理、紋理投影;

2、使用不同Lod梯度的紋理查找;

3、跨像素Quad的梯度計算。

  • 最小化shader指令數。

現代shader編譯器通常會執行特定的指令優化,但它不是自動有效的。很多時候需要人工介入,分析著色器,儘可能減少指令,即使是節省一條指令也值得。

  • 避免使用全能著色器(uber-shader)。

uber-shader使用靜態分支組合多個著色器到一個單一的著色器。如果試圖減少狀態更改和批處理繪製調用,那麼是有意義的。然而,通常會增加GPR數量,從而影響性能。

  • 高效地取樣紋理。

紋理取樣(過濾)的方式很多,性能和效果通常成反比:

紋理的部分過濾類型及對應效果圖。

要做到高效地取樣紋理,必須遵循以下規則:

1、避免隨機訪問,保持取樣在同一個2×2像素Quad內,命中率高,著色器更有效率。

2、避免使用3D紋理。由於需要執行複雜的過濾來計算結果值,從體積紋理中獲取數據通常比較昂貴。

3、限制Shader紋理取樣數量。在一個著色器中使用四個取樣器是可以接受的,但取樣更多的紋理可能會導致性能瓶頸。

4、壓縮所有紋理。這允許更好的記憶體使用,轉化為渲染管道中更少的紋理停頓。

5、考慮開啟Mipmaps。Mipmaps有助於合併紋理獲取,並有助於以增加記憶體佔用為代價的提高性能。同時還能降低頻寬,提升快取命中率。

6、盡量使用簡單的紋理過濾。性能從高到低(效果從低到高)的取樣方式:最近點(nearest)、雙線性(bilinear)、立方(cubic)、三線性(tri-linear)、各向異性(anisotropic)。越複雜的取樣方式,會讀取越多的數據,從而提升記憶體訪問頻寬,降低快取命中率,造成更大的延遲。需要格外注意這一點。

7、優先使用texelFetch / texture(),通常會比紋理取樣效率更高(但需要工具分析驗證)。

8、謹慎對待預計算紋理LUT。實時渲染中,很常將複雜計算的結果編碼到紋理中,並將其用作查找表(如IBL的輻照度圖,皮膚次表面散射預積分圖)。這種方式只會在著色器是瓶頸時提升性能。如果函數參數和查找表中的紋理坐標在相鄰片元之間相差很大,那麼快取效率就會受到影響。應該執行性能概要分析,以確定此法是否有實際上的提升。

9、使用mediump sampler代替highp sampler,後者的速度是前者的一半。

10、各向異性過濾(Anisotropic Filtering,AF)優化建議:

(1)先使用2x各向異性,評估它是否滿足品質要求。較高的樣本數量可以提高品質,但也會帶來效益遞減,並且往往與性能成本不相稱。

(2)考慮使用2x雙線性各向異性,而非三線性各向同性。在各向異性高的區域,2x雙線性演算法速度更快,影像品質更好。注意,通過切換到雙線性過濾,可以在mipmap級別之間的過度點上看到接縫。

(3)只對受益最大的對象使用各向異性和三線性濾波。注意,8x三線性各向異性的消耗是簡單雙線性過濾的16倍!

  • 盡量避免依賴紋理讀取(Dependent texture read)。

依賴紋理讀取是一種特殊的紋理讀取,其中紋理坐標依賴於著色器中的一些計算(而不是某種規律變化)。由於這個計算的值不能提前知道,它不可能預取紋理數據,因此在著色器處理降低快取命中率,引發卡頓。

頂點著色紋理查找總是被視作依賴紋理讀取,就像片元著色中基於zw通道變化的紋理讀取。在一些驅動程式和平台版本中,如果給定帶有無效w的Vec3或Vec4,則Texture2DProj()也可以作為依賴紋理讀取。

與依賴紋理讀取相關的成本在某種程度上可以通過硬體執行緒調度來抵消,特別是著色器涉及大量的數學計算。這個過程涉及到執行緒調度程式暫停當前執行緒並在另一個執行緒中交換到USC上的處理。這個交換的執行緒將儘可能多地處理,一旦紋理獲取完成,原始執行緒將被交換回(下圖)。

GPU的Context需要訪問快取或記憶體,會導致若干個時鐘周期的延遲,此時調度器會激活第二組Context以利用ALU。

GPU越多Context可用就越可以提升運算單元的吞吐量,上圖的18組Context的架構可以最大化地提升吞吐量。

雖然硬體會儘力隱藏記憶體延遲,但為了獲得良好的性能,應該儘可能避免依賴紋理讀取。應用程式盡量在片元著色器執行之前就計算出紋理坐標。

  • 避免使用動態分支

動態分支會延遲shader指令時間,但如果分支的條件是常量,則編譯器就會在編譯器進行優化。否則如果條件語句和uniform、可變變數相關,則無法優化。其它建議:

1、最小化空間相鄰著色執行緒中的動態分支。

2、使用min(), max(), clamp(), mix(), saturate()等內置函數避免分支語句。

3、檢查分支相對於計算的好處。例如,跳過距離相機閾值以上的像素進行光照計算,通常比直接進行計算會更快。

  • 打包shader插值數據。

著色器插值需要GPR(General Purpose Register,通用暫存器)傳遞數據到像素著色器。GPR的數量有限,若佔滿,會導致Stall,所以盡量減少它們的使用。

能使用uniform的就不用varying。將值打包在一起,因為所有varying都有四個組件,不管它們是否被使用,比如將兩個vec2紋理坐標放入一個vec4。也存在其它更有創意的打包和實時數據壓縮。

  • 減少著色器GRP的佔用。

佔用越多的GPR(General Purpose Register,通用暫存器)意味著計算量大,如果沒有足夠的可用暫存器時,可能會導致暫存器溢出,從而導致性能欠佳。以下一些措施可以減少GRP的佔用:

1、使用更簡單的著色器。

2、修改GLSL以減少哪怕是一條指令,有時也能減少一個GPR的佔用。

3、不展開循環(unrolling loop)也可以節省GPRs,但取決於著色器編譯器。

4、根據目標平台配置著色器,確保最終選擇的解決方案是最高效的。

5、展開循環傾向於將紋理獲取放到著色器頂部,導致需要更多的GPR來保存多個紋理坐標並同時獲取結果。

6、最小化全局變數和局部變數的數量。減少局部變數的作用域。

7、最小化數據維度。比如能用2維的就不要用3維。

8、使用精度更小的數據類型。如FP16代替FP32。

  • 在著色器上避免常量的數學運算

自從著色器出現以來,幾乎每一款發行的遊戲都在著色器常量上花費了不必要的數學運算指令。需要在著色器中識別這些指令,將這些計算移到CPU上。在編譯後的程式碼中識別著色器常量的數學運算可能更容易。

  • 避免在像素著色器中使用discard等語句。

一些開發者認為,在像素著色器中手動丟棄(也稱為殺死)像素可以提高性能。實際上沒有那麼簡單,有以下原因:

1、如果執行緒中的一些像素被殺死,而同Quad的其他像素沒有,著色器仍然執行。

2、依賴於編譯器如何生成微程式碼(Microcode)。

3、某些硬體架構(如PowerVR)會禁用TBDR的優化,造成渲染管線的Stall和數據回寫。

  • 避免在像素著色器中修改深度

理由類同上一條。

  • 避免在VS里取樣紋理。

雖然目前主流的GPU已經使用了統一著色器架構,VS和PS的執行性能相似。但是,還是得確保在VS對紋理操作是局部的,並且紋理使用壓縮格式。

  • 拆分特殊的繪製調用

如果一個著色器瓶頸在於GPR和/或紋理快取,拆分Draw Call到多個Pass反而可以增加性能。但結果難以預測,應以實際性能測試為準。

  • 盡量使用低精度浮點數

FP16的運算性能通常是FP32的兩倍,所以shader中儘可能使用低精度浮點數。

precision mediump float;

#ifdef GL_FRAGMENT_PRECISION_HIGH
    #define NEED_HIGHP highp
#else
    #define NEED_HIGHP mediump
#endif
        
varying vec2 vSmallTexCoord;
varying NEED_HIGHP vec2 vLargeTexCoord;

UE也對浮點數做了封裝,以便在不同平台和畫質下自如低切換浮點數的精度。

  • 盡量將PS運算遷移到VS。

通常情況下,頂點數量明顯小於像素數量。通過將計算從像素著色器遷移到頂點著色器,可以減少GPU工作負載,有助於消除冗餘計算。

例如,拆分光照計算的漫反射和高光反射,將漫反射遷移到VS,而高光反射保留在PS中,這樣能獲得效果和效率良好平衡的光照結果。

  • 優化Uniform / Uniform Buffer。

1、保持Uniform數據儘可能地小。不超過128位元組,以便在多數GPU良好地運行任意給定的著色器。

2、將Uniform改成OpenGL ES的帶有#define的編譯時常量,Vulkan的專用常量,或者著色源中的靜態語法。

3、避免Uniform的向量或矩陣中存在常量,例如總是0或1的元素。

4、優先使用glUniform()設置uniform,而不是從buffer中載入。

5、不要動態地索引uniform數組。

6、不要過度使用實例化。使用gl_InstanceID訪問Instanced uniform就是動態索引,無法使用暫存器映射的Uniform。

7、將Uniform的相關計算儘可能地移到CPU的應用層。

8、盡量使用uniform buffer代替著色器存儲緩衝區(shader storage buffer)。只要uniform buffer空間充足,就盡量使用之。如果uniform buffer對象在GLSL中靜態索引,並且足夠小,驅動程式或編譯器可以將它們映射到用於默認統一塊全局變數的相同硬體常量RAM中。

  • 保持UBO佔用儘可能地小。

如果UBO小於8k,則可以放進常量存儲器,將獲得更高的性能。否則,會存儲在全局記憶體,存取時間周期顯著增加。

  • 選擇更優的著色演算法。

選擇更優的有效的演算法比低級別(指令級)的優化更重要。因為前者更能顯著地提升性能。

  • 選擇合適的坐標空間。

頂點著色器的一個常見錯誤是在模型空間、世界空間、視圖空間和剪輯空間之間執行不必要的轉換。如果模型世界轉換是剛體轉換(只包含旋轉、平移、鏡像、光照或類似),那麼可以直接在模型空間中進行計算。

避免將每個頂點的位置轉換為世界或視圖空間,更好的做法是將uniforms(如光的位置和方向)轉換到模型空間,因為它是一個逐網格的操作,計算量更少。在必須使用特定空間的情況下(例如立方體映射反射),最好整個Shader都使用這個空間,避免在同一個shader中使用多個坐標空間。

  • 優化插值(Varying)變數。

減少插值變數數量,減少插值變數的維度,刪除無用(片元著色器未使用)的插值變數,緊湊地打包它們,儘可能使用中低精度數據類型。

  • 優化原子(Atomic)。

原子操作在許多計算演算法和一些片元演算法中比較常見。通過一些微小的修改,原子操作允許許多演算法在高度並行的GPU上實現,否則將是串列的。

原子的關鍵性能問題是爭用(contention)。原子操作來自不同的著色器核心。要達到相同的高速快取行(cache line),需要數據一致性訪問L2高速快取。

通過將原子操作保持在單個著色器核心來避免爭用,當著色器核心在L1中控制必要的快取行時,原子是最高效的。以下是具體的優化建議:

1、考慮在演算法設計中使用原子時如何避免爭用。

2、考慮將原子間距設置為64個位元組,以避免多個原子在同一高速快取行上競爭。

3、考慮是否可以通過累積到共享記憶體原子中來分攤爭用。然後,讓其中的一個執行緒在工作組的末尾推送全局原子操作。

  • 充分利用指令快取(Instruction cache)。

著色器核心指令快取是一個經常被忽略的影響性能的因素。由於並發運行的執行緒數量眾多,因此足夠重視指令快取對性能的重要性。優化建議如下:

1、使用較短的著色器與更多的執行緒,而不是更長的色器與少量的執行緒。較短的著色器指令在快取中更有可能被命中。

2、使用沒有動態分支的著色器。動態分支會減少時間局部性,增加快取壓力。

3、不要過於激進地展開循環(unroll loop),儘管一些展開可能有所幫助。

4、不要從相同的源程式碼生成重複的著色程式或二進位文件。

5、小心同個tile記憶體在多個可見的片元著色(即Overdraw)。所有未被Early-ZS或FPK/HSR剔除的片元著色器,必須載入和執行,增加快取壓力。

12.6.3.3 彙編級優化

建議Shader低級別優化只在性能異常敏感的地方或者優化後期才關注和執行,否則可能事倍功半。

對於GPU指令集,很多指令可以在1個時鐘周期完成,但有些指令則需要多個周期。下圖是PowerVR的部分可以在1個時鐘周期完成的指令:

對於峰值性能的測量,若以PowerVR 500MHz G6400為例,則常見指令的峰值性能數據如下:

數據類型 操作 單指令操作數 單指令時鐘 理論吞吐量
16-bit float Sum-Of-Products 6 1 (0.5 × 4 × 16 × 6) ÷ 1 = 192 GFLOPS
float Multiply-and-Add 4 1 (0.5 × 4 × 16 × 4) ÷ 1 = 128 GFLOPS
float Multiply 2 1 (0.5 × 4 × 16 × 2) ÷ 1 = 64 GFLOPS
float Add 2 1 (0.5 × 4 × 16 × 2) ÷ 1 = 64 GFLOPS
float DivideA 1 4 (0.5 × 4 × 16 × 1) ÷ 4 = 8 GFLOPS
float DivideB 1 2 (0.5 × 4 × 16 × 1) ÷ 2 = 16 GFLOPS
int Multiply-and-Add 2 1 (0.5 × 4 × 16 × 2) ÷ 1 = 64 GILOPS
int Multiply 1 1 (0.5 × 4 × 16 × 1) ÷ 1 = 32 GILOPS
int Add 1 1 (0.5 × 4 × 16 × 1) ÷ 1 = 32 GILOPS
int Divide 1 30 (0.5 × 4 × 16 × 1) ÷ 30 = 1.07 GILOPS

性能估計以理論上的峰值來計算,實際上由於各種依賴、降頻、上下文切換等原因,可能實際峰值達不到。

默認情況下,編譯器將浮點除法實現為兩個範圍縮減,然後是倒數和乘法指令,需要4個循環。

另外,重點提一下整數除法,效率極低,應該避免,可以先轉成float再除。

更多指令的消耗情況可參見Complex Operations

下面是常見的低級別優化措施(以PowerVR為例,其它GPU類似但不完全相同,應以實測為準)。

1、為了充分利用USC核心,必須始終以乘-加(MAD)形式編寫數學表達式。例如,更改以下表達式以使用MAD表單可以減少50%的周期成本:

fragColor.x = (t.x + t.y) * (t.x - t.y); // 2 cycles
{sop, sop, sopmov}
{sop, sop}
-->
fragColor.x = t.x * t.x + (-t.y * t.y); // 1 cycle
{sop, sop}

2、通常最好以倒數形式寫除法,因為倒數形式直接由指令RCP支援。完成數學表達式的簡化可以進一步提高性能。

fragColor.x = (t.x * t.y + t.z) / t.x; // 3 cycles
{sop, sop, sopmov}
{frcp}
{sop, sop}
-->
fragColor.x = t.y + t.z * (1.0 / t.x); // 2 cycles
{frcp}
{sop, sop}

3、sign(x)的結果可能是以下幾種:

if (x > 0)
{
    return 1;
}
else if(x < 0)
{
    return -1;
}
else
{
    return 0;
}

但利用sign來獲取符號並非最優選擇:

fragColor.x = sign(t.x) * t.y; // 3 cycles
{mov, pck, tstgez, mov}
{mov, pck, tstgez, mov}
{sop, sop}
-->
fragColor.x = (t.x >= 0.0 ? 1.0 : -1.0) * t.y; // 2 cycles
{mov, pck, tstgez, mov}
{sop, sop}

4、使用inversesqrt代替sqrt

fragColor.x = sqrt(t.x) > 0.5 ? 0.5 : 1.0; // 3 cycles
{frsq}
{frcp}
{mov, mov, pck, tstg, mov}
-->
fragColor.x = (t.x * inversesqrt(t.x)) > 0.5 ? 0.5 : 1.0; // 2 cycles
{frsq}
{fmul, pck, tstg, mov}

5、normalize的取反優化:

fragColor.xyz = normalize(-t.xyz); // 7 cycles
{mov, mov, mov}
{fmul, mov}
{fmad, mov}
{fmad, mov}
{frsq}
{fmul, fmul, mov, mov}
{fmul, mov}
-->
fragColor.xyz = -normalize(t.xyz); // 6 cycles
{fmul, mov}
{fmad, mov}
{fmad, mov}
{frsq}
{fmul, fmul, mov, mov}
{fmul, mov}

6、abs、dot、neg、clamp、saturate等優化:

// abs
fragColor.x = abs(t.x * t.y); // 2 cycles
{sop, sop}
{mov, mov, mov}
-->
fragColor.x = abs(t.x) * abs(t.y); // 1 cycle
{sop, sop}

// dot
fragColor.x = -dot(t.xyz, t.yzx); // 3 cycles
{sop, sop, sopmov}
{sop, sop}
{mov, mov, mov}
-->
fragColor.x = dot(-t.xyz, t.yzx); // 2 cycles
{sop, sop, sopmov}
{sop, sop}

// clamp
fragColor.x = 1.0 - clamp(t.x, 0.0, 1.0); // 2 cycles
{sop, sop, sopmov}
{sop, sop}
-->
fragColor.x = clamp(1.0 - t.x, 0.0, 1.0); // 1 cycle
{sop, sop}

// min / clamp
fragColor.x = min(dot(t, t), 1.0) > 0.5 ? t.x : t.y; // 5 cycles
{sop, sop, sopmov}
{sop, sop}
{mov, fmad, tstg, mov}
{mov, mov, pck, tstg, mov}
{mov, mov, tstz, mov}
-->
fragColor.x = clamp(dot(t, t), 0.0, 1.0) > 0.5 ? t.x : t.y; // 4 cycles
{sop, sop, sopmov}
{sop, sop}
{fmad, mov, pck, tstg, mov}
{mov, mov, tstz, mov}

7、Exp、Log、Pow:

// exp2
fragColor.x = exp2(t.x); // one cycle
{fexp}

// exp
float exp( float x )
{
    return exp2(x * 1.442695); // 2 cycles
    {sop, sop}
    {fexp}
}

// log2
fragColor.x = log2(t.x); // 1 cycle
{flog}

// log
float log( float x )
{
    return log2(x * 0.693147); // 2 cycles
    {sop, sop}
    {flog}
}

// pow
float pow( float x, float y )
{
    return exp2(log2(x) * y); // 3 cycles
    {flog}
    {sop, sop}
    {fexp}
}

執行效率從高到低:exp2 = log2 > exp = log > pow。

8、Sin、Cos、Sinh、Cosh:

// sin
fragColor.x = sin(t.x); // 4 cycles
{fred}
{fred}
{fsinc}
{fmul, mov} // plus conditional

// cos
fragColor.x = cos(t.x); // 4 cycles
{fred}
{fred}
{fsinc}
{fmul, mov} // plus conditional

// cosh
fragColor.x = cosh(t.x); // 3 cycles
{fmul, fmul, mov, mov}
{fexp}
{sop, sop}

// sinh
fragColor.x = sinh(t.x); // 3 cycles
{fmul, fmul, mov, mov}
{fexp}
{sop, sop}

執行效率從高到低:sinh = cosh > sin = cos。

9、Asin, Acos, Atan, Degrees, and Radians:

fragColor.x = asin(t.x); // 67 cycles
fragColor.x = acos(t.x); // 79 cycles
fragColor.x = atan(t.x); // 12 cycles (許多判斷條件)

fragColor.x = degrees(t.x); // 1 cycle
{sop, sop}

fragColor.x = radians(t.x); // 1 cycle
{sop, sop}

從上可知,acos和asin效率極其低,高達79個時鐘周期;其次是atan,12個時間周期;最快的是degrees和radians,1個時鐘周期。

10、向量和矩陣:

fragColor = t * m1; // 4x4 matrix, 8 cycles
{mov}
{wdf}
{sop, sop, sopmov}
{sop, sop, sopmov}
{sop, sop}
{sop, sop, sopmov}
{sop, sop, sopmov}
{sop, sop}

fragColor.xyz = t.xyz * m2; // 3x3 matrix, 4 cycles
{sop, sop, sopmov}
{sop, sop}
{sop, sop, sopmov}
{sop, sop}

向量和矩陣的維度的數量越少效率越高,所以盡量縮減它們的維度。

11、標量、向量運算:

fragColor.x = length(t-v); // 7 cycles
fragColor.y = distance(v, t);
{sopmad, sopmad, sopmad, sopmad}
{sop, sop, sopmov}
{sopmad, sopmad, sopmad, sopmad}
{sop, sop, sopmov}
{sop, sop}
{frsq}
{frcp}
-->
fragColor.x = length(t-v); // 9 cycles
fragColor.y = distance(t, v);
{mov}
{wdf}
{sopmad, sopmad, sopmad, sopmad}
{sop, sop, sopmov}
{sop, sop, sopmov}
{sop, sop}
{frsq}
{frcp}
{mov}

fragColor.xyz = normalize(t.xyz); // 6 cycles
{fmul, mov}
{fmad, mov}
{fmad, mov}
{frsq}
{fmul, fmul, mov, mov}
{fmul, mov}
-->
fragColor.xyz = inversesqrt( dot(t.xyz, t.xyz) ) * t.xyz; // 5 cycles
{sop, sop, sopmov}
{sop, sop}
{frsq}
{sop, sop}
{sop, sop}

fragColor.xyz = 50.0 * normalize(t.xyz); // 7 cycles
{fmul, mov}
{fmad, mov}
{fmad, mov}
{frsq}
{fmul, fmul, mov, mov}
{fmul, fmul, mov, mov}
{sop, sop}
-->
fragColor.xyz = (50.0 * inversesqrt( dot(t.xyz, t.xyz) )) * t.xyz; // 6 cycles
{sop, sop, sopmov}
{sop, sop}
{frsq}
{sop, sop, sopmov}
{sop, sop}
{sop, sop}

以下是GLSL部分內置函數的展開形式:

vec3 cross( vec3 a, vec3 b )
{
    return vec3( a.y * b.z - b.y * a.z,
                 a.z * b.x - b.z * a.x,
                 a.x * b.y - b.y * a.y );
}

float distance( vec3 a, vec3 b )
{
    vec3 tmp = a – b;
    return sqrt( dot(tmp, tmp) );
}

float dot( vec3 a, vec3 b )
{
    return a.x * b.x + a.y * b.y + a.z * b.z;
}

vec3 faceforward( vec3 n, vec3 I, vec3 Nref )
{
    if( dot( Nref, I ) < 0 ) 
    { 
      return n;
    }
    else
    {
      return –n:
    }
}

float length( vec3 v )
{
    return sqrt( dot(v, v) );
}

vec3 normalize( vec3 v )
{
    return v / sqrt( dot(v, v) );
}

vec3 reflect( vec3 N, vec3 I )
{
    return I - 2.0 * dot(N, I) * N;
}

vec3 refract( vec3 n, vec3 I, float eta )
{
    float k = 1.0 - eta * eta * (1.0 - dot(N, I) * dot(N, I));
    if (k < 0.0)
        return 0.0; 
    else
        return eta * I - (eta * dot(N, I) + sqrt(k)) * N;
}

12、分組運算。

將標量和向量一次分組,可以提升效率:

fragColor.xyz = t.xyz * t.x * t.y * t.wzx * t.z * t.w; // 7 cycles
{sop, sop, sopmov}
{sop, sop, sopmov}
{sop, sop}
{sop, sop, sopmov}
{sop, sop}
{sop, sop, sopmov}
{sop, sop}
-->
fragColor.xyz = (t.x * t.y * t.z * t.w) * (t.xyz * t.wzx); // 4 cycles
{sop, sop, sopmov}
{sop, sop, sopmov}
{sop, sop}
{sop, sop}

以上彙編指令以PowerVR GPU為案例,其它的類似但可能不完全一樣,需要視具體平台執行優化。

12.6.4 綜合優化

12.6.4.1 光影優化

前向渲染適合簡單的只有少量動態光源的場景。

傳統延遲渲染適合很多動態光源(特別是小範圍的局部光源)的場景。但是,受限於Tile內緩衝區的頻寬位數,不能存儲過多的幾何表面資訊。

基於Compute Shader的光照技術(如tiled deferred、cluster deferred、forward+)由於MRT的數據極可能超出Tile內緩衝區,會向全局記憶體寫入數據,造成極大的訪問周期和延遲。不建議用於移動端。

陰影技術有很多,但最適合TBR硬體架構的陰影技術當屬模板陰影(stencil shadowing)。因為TBR的GPU硬體非常擅長處理模板緩衝區,數據存儲在Tile記憶體中,不需要寫入系統記憶體。如果硬陰影是可接受的,應該優先使用模板陰影演算法。

需要將結果寫入片外存儲器的技術(如陰影圖),通常比完全在Tile記憶體儲器中計算的技術性能更差。

如果要使用SSAO技術,為了防止頻繁隨機高跨度地訪問深度緩衝,最好使用HZB(層級Z-Buffer)來加速。

如果需要使用SSR技術,提前為場景顏色的Frame Buffer做下取樣(使用OpenGL ES介面glFramebufferTexture2DDownsampleIMG)。

優先使用模板裁剪光照演算法,而不是傳統的分塊、分簇光照。

對移動端的光照進行特殊優化,例如Filament對光照的可見性函數進行了簡化:

簡化後的可見性公式如下所示:

對應的實現程式碼:

float V_SmithGGXCorrelated_Fast(float roughness, float NoV, float NoL) 
{
    // Hammon 2017, "PBR Diffuse Lighting for GGX+Smith Microsurfaces"
    return 0.5 / mix(2.0 * NoL * NoV, NoL + NoV, roughness);
}

對IBL光照部分,Filament摒棄了Diffuse Map,直接採納Specular Map(粗糙度為1時的Mimap level)來模擬:

但是Specular Map只有5級,最小的尺寸是16×16:

Mimap級別映射到粗糙度如下:

Mipmap級別 粗糙度
0 0.000
1 0.018
2 0.086
3 0.250
4 1.000

存儲IBL的紋理時沒有使用RGBM格式(因為品質不達標),而是採用R11G11B10F的格式,重組成RGBA8888,以PNG格式存儲。

對於金屬物體,為了接近傳統PBR光照的能量不守恆問題,Filament採用了Lagarde & Golubev的方案:

傳統PBR在計算金屬材質時存在能量不守恆問題。上排是不守恆的圖例,越黑標明丟失的能量越多,下排是Filment修復後的圖例(要非常仔細才能看清)。

Filament修復金屬光照不守恆的BRDF公式。

實現的程式碼如下:

// 傳統的IBL計算程式碼
const float V = Visibility(…) * NoL * (VoH / NoH);
const float F = pow5(1.0f - VoH);
r.x += V * (1.0f - F);
r.y += V * F;

// Filament修復後的程式碼
const float V = Visibility(…) * NoL * (VoH / NoH);
const float F = pow5(1.0f - VoH);
r.x += V * F;
r.y += V;

在AO方面,Filament模擬了多反彈(Multi-bounce)效果:

Filament的多反彈AO效果對比圖。上:關閉多反彈;下:開啟多反彈,注意眼睛和耳朵明亮了少許。

AO多反彈的模擬程式碼如下:

vec3 gtaoMultiBounce(float visibility, const vec3 albedo) 
{
 // Jimenez et al. 2016,
 // 「Practical Realtime Strategies for Accurate Indirect Occlusion"
 vec3 a = 2.0404 * albedo - 0.3324;
 vec3 b = -4.7951 * albedo + 0.6417;
 vec3 c = 2.7552 * albedo + 0.6903;
 return max(vec3(visibility), ((visibility * a + b) * visibility + c) * visibility);
}

diffuseLobe *= gtaoMultiBounce(ao, diffuseColor);

陰影方面也存在不少優化技術。例如,下圖是Sample distribution shadow map(SDSM,樣本分布陰影圖)技術展示通過計算物體包圍盒代替視錐體包圍盒來減少陰影圖的尺寸:

SDSM還通過構造HZB並使用上一幀的HZB來避免GPU卡頓,利用CS生成級聯子陰影圖的距離,生成的HZB可以用於快速裁剪級聯子陰影圖。通過這些優化措施,SDSM可以均衡每個級聯的圖元數量,可以均衡陰影圖解析度和輸出解析度,可以用更小的解析度獲得和非SDSM方法的類似陰影效果。以下是SDSM和非SDSM的效果對比圖:

上:普通CSM陰影;下:SDSM陰影。

在性能方面,SDSM的表現也要勝出一籌:

如果在低端設備,可以嘗試使用圓團(Blob)陰影代替陰影圖:

左:陰影圖;右:Blob陰影。

在高級光照方面,可以嘗試Forward+、Light Prepass等光照渲染技術。以下是各種光照技術在移動GPU的對比圖:

此外,可以嘗試使用MatCap技術來實現IBL效果,可以獲得性能和效果良好的平衡:

利用MatCap技術實現的渲染效果。

12.6.4.2 後處理優化

後處理效果會佔用更大的頻寬,所以非必須,盡量關閉所有後處理效果。

如果確實需要後處理,常見的優化手段如下:

1、將多個後處理效果合併成一個Shader完成。

2、降解析度計算後處理效果。

3、盡量將後處理的數據訪問保持在Tile內。

4、盡量不訪問周邊像素數據。如果需要,盡量保持局部性和時效性,提升快取命中率。

5、專用的演算法優化。如將高斯模糊拆分成橫向模糊+豎向模糊(分離卷積核)。Filament對針對移動端的色調映射做了優化:

// 原始的ACES色調映射。
vec3 Tonemap_ACES(const vec3 x)
{
    // Narkowicz 2015, "ACES Filmic Tone Mapping Curve」
    const float a = 2.51;
    const float b = 0.03;
    const float c = 2.43;
    const float d = 0.59;
    const float e = 0.14;
    return (x * (a * x + b)) / (x * (c * x + d) + e);
}

// 移動端版本的色調映射
vec3 Tonemap_Mobile(const vec3 x) 
{
    // Transfer function baked in,
    // don』t use with sRGB OETF!
    return x / (x + 0.155) * 1.019;
}

而簡化後的色調映射曲線非常接近:

Arm對常用的後處理效果在不同的品質等級下給出了技術參考:

12.6.4.3 精靈渲染優化

優化精靈渲染的常見手段有:控制精靈的數量,控制精靈在螢幕的面積,減少空白區域。

現代GPU對圖元數量的增加沒有那麼敏感,而對開啟了Alpha Blend的精靈(Sprite)空白區域的增加,反而更加敏感,因為會浪費很多無效的片元著色處理。避免空白區域浪費的一種有效手段是增加繪製精靈的幾何體複雜性,通過增加幾何複雜度來減少透明性的浪費,可以顯著提高性能。

以往在用精靈模擬粒子特效時,會用一個四邊形繪製一個圓形,此時四邊形周邊都是空白區域,浪費比例達到驚人的22%。如果將4邊形增加到12邊形,那麼浪費的片元處理數量就可以減少到3%。它們的公式和結果對比如下:

4邊形和12邊形在浪費的片元處理數量比例對比圖。圖左是4邊形,浪費比例為21.4%;圖右是12邊形,浪費比例為2.9%。

使用8邊形繪製圓形半透明物體的圖例。

另外,可以將將不透明和半透明對象(如UI元素)分割成單獨的繪製提交。繪製提交順序建議如下:

1、不透明的場景精靈元素。

2、半透明的場景精靈元素。

3、半透明的UI元素。

對於粒子特效,遠處的粒子清晰度無關緊要,可以使用更簡單的紋理過濾方式(如最近點)。

12.6.4.4 均衡GPU工作負載

對於GPU密集型的應用程式,瓶頸常常會發生在GPU側,而發生的原因是GPU各部件的工作負載不均衡,導致了性能瓶頸。

通過合理分攤GPU各部件的工作負載,可以有效消除瓶頸,充分發揮GPU的功效,提升渲染性能。以下是可區分工作負載的GPU資源:

1、ALU(邏輯運算單元)

2、Texturing Load(紋理載入)

3、ISP Load(影像綜合處理器載入)

4、Renderer Active(渲染器活動)

5、Tiler Active(分塊器活動)

通過GPU廠商提供的Profiler(如Snapdragon Profiler、PVRMonitor),可以有效監控它們的動態。下面是均衡工作負載的優化描述:

1、使用預計算並將結果存儲在查找表(LUT),可以將ALU的工作轉移到Texturing Load。

2、使用程式紋理函數代替紋理獲取,可以將Texturing Load的工作轉移到ALU。

3、使用深度和模板測試減少著色器調用,從而減少紋理負載或ALU的工作量。

4、基於ALU的Alpha test可以用來交換深度prepass和深度測試,會增加draw call和幾何開銷,但可以顯著降低ALU工作量和暫存器壓力。

5、Alpha test和雜訊函數結合使用來實現LOD過渡效果,會極大提升ALU工作量。在這種情況下,可以執行模板prepass來轉移ALU工作量到ISP。

6、高保真畫質可以提供更複雜的著色器、更高的解析度紋理、增加多邊形數量來提高。當渲染達到瓶頸時,更好的做法是增加多邊形數量,而不是增加片元著色器的複雜度。

12.6.4.5 Compute Shader優化

Compute Shader(計算著色器,CS)之前,有多種方法可以在OpenGL ES中暴露令人尷尬的並行計算:

1、光柵化一個四邊形,並在像素著色器中執行任意計算,然後可以將結果寫入紋理中。

2、使用變換回饋在頂點著色器中執行任意計算。

這些方法都存在諸多限制,比如著色器不能感知其它著色器,寫入數據的目標是限制死的(VS只能寫入gl_Position和變數暫存器中,PS只能寫入指定的RenderTarget中)。

而Compute Shader沒有上述的限制,可以指定任意的輸入、輸出數據源,並且不用跑傳統的渲染管線,可以方便、高效、靈活地運行自定義的計算。

每個Compute Shader派發任務時,可以指定Work Group的數量,以及每個Work Group的執行緒數量。

上:每次派發Compute Shader的Work Group示意圖;下:每個Work Group有若干條執行緒,這些執行緒有一個Shared Memory。

Compute Shader運行的偽程式碼如下:

for (int w = 0; w < NUM_WORK_GROUPS; w++)
{
    // 保證並行運行。
    parallel_for (int i = 0; i < THREADS_IN_WORK_GROUP; i++)
    {
        execute_compute_thread(w, i);
    }
}

對workgroup尺寸,建議如下:

1、使用64作為工作組的基準(baseline)大小。每個工作組使用的執行緒不要超過64個。

2、使用4的倍數作為工作組的大小。

3、在更大的工作組之前嘗試更小的工作組尺寸,特別是在使用barrier或shared memory的情況下。

4、在處理影像或紋理時,使用方形執行維度(例如8×8)來利用最優的2D快取局域性。

5、如果一個工作組要完成每個工作組的工作,考慮將工作拆分成兩個通道。這樣做可以避免barrier和內核中大多數執行緒產生空閑間隙。小尺寸工作組的barrier也會產生性能成本。

6、Compute Shader的性能並不總是直觀的,所以要持續不斷測量性能。

對Shared memory,建議如下:

1、使用共享記憶體以便在工作組中的執行緒之間共享重要或複雜的計算。

2、保持共享記憶體儘可能小,因為這樣可以減少數據快取的急劇變化。

3、降低精度和數據寬度以減少所需的共享記憶體的大小。

4、需要設置barrier來同步訪問共享數據。從桌面開發中移植過來的著色器程式碼有時會因為GPU特定的假設而忽略一些barrier。但這種假設在移動GPU上使用是不安全的。

5、與插入barrier相比,分割演算法到多個著色器上計算更高效。

6、對於障礙,較小的工作組更便宜。

7、不要將數據從全局記憶體複製到共享記憶體,會降低快取命中率。

8、不要使用共享記憶體來實現程式碼。例如:

if (localInvocationID == 0) 
{
    common_setup();
}

barrier();
(.....) // 逐執行緒的shader邏輯
barrier();

if (localInvocationID == 0) 
{
    result_reduction();
}

上面的程式碼中common_setupresult_reduction只需要一個執行緒,工作組內的其它執行緒將在等待,產生Stall和空閑。

將上面的程式碼分拆成三個著色器更佳,因為common_setupresult_reduction只需更少的執行緒。

然而尷尬的是UE的CS程式碼中大量使用了這種合併的程式碼,如TAA、SSGI等等。

對Image(或Texture)的處理建議如下:

1、當使用變化插值時,紋理坐標將使用固定功能硬體(fixed function hardware)進行插值。反過來,釋放著色器周期以獲得更有用的工作負載。

2、寫入記憶體可以使用Tile-Writeback硬體與shader程式碼並行完成。

3、不需要範圍檢查imageStore()坐標。當使用不完全細分一幀的工作組時,可能會出現問題。

4、可以進行幀緩衝壓縮和傳輸消除(transaction elimination,僅Mali GPU)。

下面是使用compute進行影像處理的一些優點:

1、可以利用相鄰像素之間的共享數據集,可以避免一些演算法的額外傳輸。

2、在每個執行緒中使用更大的工作集更容易,從而避免了一些演算法的額外傳輸。

3、對於像FFT(快速傅里葉變換)這樣需要多個片元渲染通道的複雜演算法,通常可以合併到單個計算分派(dispatch)中。

12.6.4.6 多核並行

多核已經是目前移動端SoC的主流CPU的標配(多數CPU已經達到8核或更多),如何利用多核的並行能力提升渲染效果,是個非常龐大且具有挑戰性的任務。

首先充分利用現代圖形API(DirectX12、Vulkan、Metal)允許多核創建、執行Command Buffer的特性,以提升並行效率:

Vulkan圖形API並行生成Command Buffer示意圖。

Filament的作業系統並行渲染圖示如下:

filamente作業系統簡化圖例。每個塊代表一個新的父作業,它本身可以生成N個作業。這個系統中的每個循環都是多執行緒的,並且是作業化的。

UE則使用TaskGraph進行並行化的渲染。此方面的更多技術可以參看:剖析虛幻渲染體系(02)- 多執行緒渲染

12.6.4.7 其它綜合優化

  • 系統集成優化

大多數移動平台使用垂直同步訊號顯示,以防止螢幕撕裂緩衝區交換。如果GPU渲染的速度比垂直同步周期慢,那麼一個只包含兩個緩衝區的交換鏈很容易使GPU卡頓。優化交換鏈(swap chain)的建議:

1、如果應用程式總是比vsync運行得,那麼就不要在交換鏈中使用兩個表面(Surface)。

1、如果應用程式總是比vsync運行得,那麼在交換鏈中使用兩個表面,可以減少記憶體消耗。

2、如果應用程式有時比vsync運行得,那麼在交換鏈中使用三個表面,可以給應用程式提供最佳性能。

  • 高效地使用MRT

MRT(Multiple Render Target)已在移動端普遍地支援,常見的使用案例是延遲渲染,在幾何Pass階段需要MRT來存儲表面的幾何資訊(基礎色、法線、深度、材質)。

TBDR可以利用Tile緩衝區(如PLS、Subpass),保持MRT數據在高速快取上,從而提升記憶體數據訪問速度,減少延遲。

為了在大多數移動端GPU上良好地工作,MRT的每像素數據尺寸盡量控制在128位(16位元組)內+一個深度模板緩衝,在一些更新的GPU上,可以增加到256位(32位元組)+一個深度模板緩衝。如果超出,Tile內緩衝區不足,GPU會強制將數據保存在全局記憶體,大大降低數據操作速度。

除了記憶體事務和性能考慮之外,當渲染目標在系統記憶體中溢出時,並不是所有渲染目標格式都會在系統記憶體匯流排上以全速(full rate)支援。因此,根據GPU中可用的格式和紋理處理單元(TPU),傳輸速率可能會進一步降低。對於PowerVR GPU而言,以下格式和速率關係如下:

1、RGBA8可以全速讀取。

2、RGB10A2可以接近全速讀取。

3、RG11B10只能半速讀取。

4、RGBA16F只能半速讀取。

5、RGBA32F只能1/4全速讀取(沒有雙線性過濾)。

  • 選擇適合的HDR像素格式

對於HDR,有幾種格式可以供選擇,考量的因素包含記憶體頻寬、精度(品質)、alpha支援等。對於硬體本身支援的HDR紋理格式,可以使用RGB10A2或RGBA16F,但會增加頻寬。這些紋理提供了品質、性能(過濾)和記憶體頻寬使用之間的良好平衡。

RGBMRGBdiv8紋理格式都要求開發者在著色器中實現編碼和解碼功能,需要額外的USC周期,因為它們不被硬體支援。如果應用程式受到USC限制,就不應該使用這些格式。它們的優勢在記憶體頻寬非常低,與RGBA8的頻寬成本相同。 如果應用程式受到記憶體頻寬的限制,那麼研究這些格式可能會很有用。其中RGBM和HDR Color之間編解碼程式碼如下:

// 將HDR顏色編碼成RGBM.
float4 RGBMEncode( float3 color ) 
{
    float4 rgbm;
    color *= 1.0 / 6.0;
    // 將HDR顏色的係數編碼到Alpha通道中.
    rgbm.a = saturate( max( max( color.r, color.g ), max( color.b, 1e-6 ) ) );
    rgbm.a = ceil( rgbm.a * 255.0 ) / 255.0;
    rgbm.rgb = color / rgbm.a;
    return rgbm;
}

// 解碼RGBM成HDR顏色.
float3 RGBMDecode( float4 rgbm ) 
{
    return 6.0 * rgbm.rgb * rgbm.a;
}

常見的HDR格式詳細描述如下表:

紋理格式 頻寬消耗 USC消耗 過濾 精度 Alpha
RGB10A2 1x RGBA8 硬體加速,比RGBA8稍慢 RGB通道更高的精度,以alpha精度為代價 只有四個值
RGBA16F 2x RGBA8 硬體加速,0.5x RGBA8 遠高於RGBA8的精度 支援
RG11B10F 2x RGBA8 硬體加速,0.5x RGBA8 等同RGBA16F 不支援
RGBA32F 4x RGBA8 硬體加速,0.25x RGBA8,僅支援最近點 遠高於RGBA16F的精度 支援
RGBM (RGBA8) 1x RGBA8 編碼/解碼數據 硬體不支援此格式過濾 RGB值範圍比RGBA8高 不支援
RGBdiv8(RGBA8) 1x RGBA8 比RGBM稍複雜 硬體不支援此格式過濾 同上 不支援

可以優先考慮打包的32位格式(RGB10_A2、RGB9_E5),來代替FP16或FP32。

  • 選擇合適的抗鋸齒

MSAA適用於前向渲染,TAA適應於延遲渲染。除此之外,還有眾多形態分析抗鋸齒技術,效率從高到低依次是:FXAA、CMAA、MLAA、SMAA。

因此根據項目情況選擇適合的抗鋸齒,也可以根據高中低畫質選擇不同的抗鋸齒技術。

使用MSAA時,優先使用4x,效果和效率達到較好的平衡。使用Tile內的MSAA解析,避免使用glBlitFramebuffer()等介面顯式解析。

監控、分析和對比開啟、關閉抗鋸齒技術的性能。

Filament針對高光部分執行了特殊的抗鋸齒過濾演算法(修改粗糙度):

float normalFiltering(float perceptualRoughness, const vec3 worldNormal) 
{
 // Kaplanyan 2016, "Stable specular highlights"
 // Tokuyoshi 2017, "Error Reduction and Simplification for Shading Anti-Aliasing"
 // Tokuyoshi and Kaplanyan 2019, "Improved Geometric Specular Antialiasing"
 vec3 du = dFdx(worldNormal);
 vec3 dv = dFdy(worldNormal);
 float variance = specularAntiAliasingVariance * (dot(du, du) + dot(dv, dv));
 float roughness = perceptualRoughnessToRoughness(perceptualRoughness);
 float kernelRoughness = min(2.0 * variance, specularAntiAliasingThreshold);
 float squareRoughness = saturate(roughness * roughness + kernelRoughness);
 return roughnessToPerceptualRoughness(sqrt(squareRoughness));
}
materialRoughness = normalFiltering(materialRoughness, getWorldGeometricNormalVector());
  • 儘可能將計算提前

通過將它們移到管道中需要處理的實例較少的較早位置,可以減少計算的總數。計算鏈如下:

效率從高到低依次是:預計算、CPU應用層計算、頂點著色器、像素著色器。

以光照計算為例,渲染效率從高到低:光照圖、IBL、逐頂點光照、逐像素光照。

但效率高意味著可控性差,需在效率和效果中取得平衡。

  • 雜項優化

注意電量消耗和設備溫度,防止CPU或GPU降頻導致性能下降。

考慮降解析度或幀率,或者根據某些策略動態調整。

提前載入IO負載大的數據,並且快取起來。儘可能預計算消耗大的任務。

隱藏UI介面後面的物體。(下圖)

劃分品質等級,制定好參數規格,按等級選擇不同消耗的技術。

考慮動態網格合批(UE沒有此功能,需要自己實現)。

降解析度渲染半透明物體,之後再放大混合到場景顏色中。(下圖)

此外,還需要注意多執行緒同步、遮擋剔除查詢、屏障、渲染通道、創建GPU資源和上傳、靜態靜態、記憶體佔用和泄漏、顯示記憶體佔用、VS和PS性能比例等等方面的消耗和優化。

下面是A Year in a FortniteFornite在移動端所做的部分優化圖例:

Fornite在優化(快取)描述符表之後的性能對比圖。上:優化前;下:優化後。

Fornite在優化同步消耗的前後對比圖。上:優化前;下:優化後。

Fornite在優化渲染通道的前後對比圖。左:優化前;右:優化後。

Fornite在非同步創建頂點和索引緩衝之後的效果對比圖。

Fornite對紋理上傳進行優化(分散打包到一起)的效果對比圖。

The Challenges of Porting Traha to Vulkan對Pipeline Barrier進行優化的對比圖。

Adaptive Performance in Call of Duty Mobile對性能、能耗、溫度等參數進行監控並自動動態調整的圖例。

12.6.5 XR優化

XR的渲染通常有以下特點:

1、解析度高。XR設備解析度(1536×1536、2K、4K)比普通移動設備的(720p、1080p)要高。

2、刷新率更高。普通移動設備的刷新率通常在60Hz或更少(如30Hz),而XR設備為了體驗更好,讓用戶不暈3D,必須保持60Hz以上甚至更高(72Hz、100Hz、120Hz)。

3、必須攜帶抗鋸齒。如果不帶抗鋸齒技術,XR設備的渲染畫質將出現嚴重的鋸齒和閃爍(因為螢幕離眼睛更近)。

4、每幀需要渲染兩遍(人都有兩隻眼睛嘛)。

以上的特殊設定,導致XR設備所需的頻寬是普通移動設備的9倍以上。因此,加上電量和散熱的限制,XR設備對性能異常苛刻,優化技術要求更加嚴苛。

下面是常見的XR渲染優化技術。

12.6.5.1 注視點渲染(Foveated Rendering)

由於人眼對注視點的中心清晰度要求更高,離中心點越遠,所需的清晰度遞減:

注視點渲染原理。原理是由於離眼睛注視點越近,相同的立體角覆蓋的區域越少(需要越多的像素),反之越多(需要越少的像素)。

Qualcomm的XR專用晶片利用注視點渲染技術可以提升25%的性能,並且提升渲染解析度:

Qualcomm利用OpenGL ES或Vulkan的擴展,可以讓開發人員使用詳細的參數精確地控制注視點渲染的細節:

注視點渲染效果和偏離聚焦點的清晰度曲線如下所示:

Mali使用注視點渲染技術之後總體上可以減少35%的幀快取尺寸、20%的總消耗、40%的片元著色器消耗,但會增加52%的頂點著色器消耗:

12.6.5.2 多視圖(Multiview)

Qualcomm的XR專用晶片實現了Advanced模式的多視圖渲染:

用於優化VR等渲染的MultiView對比圖。上:未採用MultiView模式的渲染,兩個眼睛各自提交繪製指令;中:基礎MultiView模式,復用提交指令,在GPU層複製多一份Command List;下:高級MultiView模式,可以復用DC、Command List、幾何資訊。

利用Multiview渲染技術,可以節省3349%的CPU時間,減少533%範圍的能耗:

Mali GPU的Multiview實現和Qualcomm不太一樣,在Fragment Shader之前都是共享同一份數據,而Fragment Shader之後則區分左右眼:

Vulkan在Multiview方面也提供了優化技術,表現在只記錄兩隻眼睛不同的命令,提供了multiview關聯的渲染通道,採用VIEW_LOCAL標記來提升Tile內利用率和快取命中率:

其它平台或圖形API實現Multiview時也有所不同:

12.6.5.3 立體渲染(Stereo Rendering)

立體渲染就是將兩隻眼睛合併成一個Pass執行渲染,從而減少Draw Call。首先需要做的是將兩隻眼睛的視錐體合併成一個:

然後用合併的視錐體進入常規的渲染流程。根據攝像機的合併策略,可以分為平行、轉位、移軸3種:

立體渲染的3種攝像機合併策略。左:平行;中:轉位;右:移軸。

下圖是平行方式的立體繪製效果(注意左右畫面略有偏移):

12.6.5.4 隱藏延時

對XR設備來說,20ms是可以接受的最大延時,意味著邏輯數據(如設備姿態)獲取到顯示器呈現不能超過20ms。假設設備以60fps運行,如果有兩幀的延遲,那麼邏輯數據從第一幀到呈現的總時長將達到50ms!!(下圖)

XR應用程式不同於普通簡單的應用程式,為了利用多核優勢,必然引入多執行緒渲染,因此在各個執行緒之間存在等待和延時。

普通應用程式渲染流程圖。

分離出遊戲執行緒和渲染執行緒的模型,可以將邏輯模擬和渲染層分離,但會引發延時。

如果在遊戲執行緒查詢XR設備的姿態(Pose)等資訊,會存在較大的延時,由此導致用戶頭暈和延滯感。解決這個問題也很簡單,就是在渲染執行緒初期中再次查詢姿態的資訊,減少延時:

當然提高幀率,合理安排並行任務以縮短時長,採用單緩衝等措施也可以降低延時。

另外,Arm針對UE4在VR方面的應用嘗試做優化,以使原本延遲3幀縮減到延遲1~2幀:

  • 系統級優化。

例如Google的Android系統工程師將Android原本的三緩衝優化成了雙緩衝,由此渲染延遲從3幀減少到1幀。

Android優化渲染延遲對比圖。上:沒有優化;下:執行了優化。

12.6.5.5 制定技術規格

由於XR設備對比普通移動設備,可以制定出來的標準將更嚴苛。

以下是Oculus官方在技術規格方面給出的一些建議:

1、Draw Call

設備 Draw Call數量 場景複雜度
Quest 1 50~150
Quest 1 150~250
Quest 1 200~400
Quest 2 80~200
Quest 2 200~300
Quest 2 400~600

2、三角面數

設備 三角面數
Quest 1 35萬~50萬
Quest 2 75萬~100萬

除了以上兩個參數,還需要注意幀率、解析度、記憶體、顯示記憶體、卡頓、電量消耗、續航時間、設備溫度等技術參數。

移動設備需要注意熱量和能量的平衡。

12.6.5.6 其它XR優化

Optimized Rendering Techniques Based On Local Cubemaps提出了基於Local Cubemap優化的渲染技術,可以高效地實現動態軟陰影、反射等效果:

Local Cubemap的概念和計算過程。

基於Local Cubemap的動態軟陰影關鍵圖例和實現。

基於Local Cubemap的動態反射關鍵圖例和實現。

How Crytek Builds 3-Dimensional UI for VR提到了渲染VR內的字體有3種技術:渲染到紋理再映射到模型、距離場、3D建模,以及詳細地對比了這幾種方式的優缺點。

渲染到紋理對小字體不夠優化,需要更高解析度。

距離場字體擁有細膩的過度和良好的抗鋸齒。但實現複雜,消耗較高。

3D網格字體。需要良好的抗鋸齒技術支援,長遠看也許是最好的選擇。

12.6.6 調試工具

性能優化和調試分析工具息息相關,正所謂工欲善其事必先利其器。

除了RenderDoc、PIX、Visual Studio、XCode等軟體或IDE提供了常規的性能分析之外,GPU廠商提供了更加專業和深入地分析自家硬體的分析工具。下面是常用的廠商和對應的分析工具表:

GPU廠商 GPU 分析軟體
Qualcomm Adreno Snapdragon Profiler
Arm Mali Arm Mobile Studio
Imagination Tech PowerVR PowerVR Graphics Tools

以Qualcomm的Snapdragon Profiler為例,它可以監控SoC的活動實況(Realtime)、追蹤某段時間內的系統和驅動的工作負載(Trace)、分析某一幀的具體渲染狀態、過程和資源(Snapshot)。

Snapdragon的Realtime頁面。

Snapdragon的Trace頁面。

Snapdragon的Snapshot頁面。

利用Snapdragon的強大監控功能,可以查看執行緒、驅動、GPU各部件消耗,查找性能瓶頸,優化卡頓、電量等。具體優化案例可以參見:Identify application bottlenecks

 

12.7 本篇總結

本篇主要闡述了UE移動端的場景渲染器的主流程,前向和延遲渲染的過程,移動端的渲染特性和光照演算法。後面兩部分超脫UE,詳細地闡述了當前移動端涉及的專用渲染技術,闡述了移動端GPU架構和運行機制,最後給出了詳盡的渲染優化建議。

關於移動端遊戲的優化,可以參閱筆者的另一篇文章移動遊戲性能優化通用技法作為補充。

移動端專題分為三部分,總字數接近6萬字,參考了100多篇各類文獻、資料和論文,是參考文獻最多的一篇。從組織、策劃、研讀論文到下筆撰寫、修改、發表,總共耗費了一個多月。

當夜深人靜本該就寢時,當周末本該輕鬆休閑時,筆者尚在奮筆疾書,雖然幾近耗盡了所有業餘時間,但成就感十足。希望對各位同學學習UE和移動端渲染有所幫助和參考,一起為中國圖形渲染技術之崛起而努力。

12.7.1 本篇思考

按慣例,本篇也布置一些小思考,以助理解和加深UE及移動端渲染的掌握和理解:

  • 請闡述UE移動端場景渲染器的主流程。
  • 請闡述UE移動端的前向和延遲渲染主流程。
  • 請闡述UE移動端光影的演算法及所做的優化。
  • 請闡述當前移動端專用的渲染技術,如TBR、Subpass等。
  • 移動端的常見渲染優化技術有哪些?請例舉一二。

 

團隊招員

部落客所在的團隊正在用UE4開發一種全新的沉浸式體驗的產品,急需各路賢士加入,共謀宏圖大業。目前急招以下職位:

  • UE邏輯開發。
  • UE引擎程式。
  • UE圖形渲染。
  • TA(技術向、美術向)。

要求:

  • 紮實的技術基礎。
  • 高度的技術熱情。
  • 良好的自驅力。
  • 良好的溝通協作能力。
  • 有UE使用經驗或移動端開發經驗更佳。

有意向或想了解更多的請添加部落客微信:81079389(註明部落格園求職),或者發簡歷到部落客郵箱:81079389#qq.com(#換成@)。

靜待各路英雄豪傑相會。

 

特別說明

  • 感謝所有參考文獻的作者,部分圖片來自參考文獻和網路,侵刪。
  • 本系列文章為筆者原創,只發表在部落格園上,歡迎分享本文鏈接,但未經同意,不允許轉載
  • 系列文章,未完待續,完整目錄請戳內容綱目
  • 系列文章,未完待續,完整目錄請戳內容綱目
  • 系列文章,未完待續,完整目錄請戳內容綱目

 

參考文獻