<input id="ohw05"></input>
  • <table id="ohw05"><menu id="ohw05"></menu></table>
  • <var id="ohw05"></var>
  • <code id="ohw05"><cite id="ohw05"></cite></code>
    <label id="ohw05"></label>
    <var id="ohw05"></var>
  • 剖析虛幻渲染體系(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尺寸是16x16像素,像素數據存儲決定了有效的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、避免隨機訪問,保持采樣在同一個2x2像素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級,最小的尺寸是16x16:

    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、在處理圖像或紋理時,使用方形執行維度(例如8x8)來利用最優的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設備分辨率(1536x1536、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的安卓系統工程師將安卓原本的三緩沖優化成了雙緩沖,由此渲染延遲從3幀減少到1幀。

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

    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(#換成@)。

    靜待各路英雄豪杰相會。

     

    特別說明

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

     

    參考文獻

    posted @ 2021-11-18 22:39  0向往0  閱讀(2507)  評論(0編輯  收藏  舉報
    国产美女a做受大片观看