移動遊戲的效能最佳化 | 材質最佳化篇

kk20161206發表於2024-06-07

材質是什麼

虛幻引擎是以hlsl著色語言為基礎,來實現vs、ps、cs等,引擎底層提供了一套翻譯系統,將hlsl翻譯成gpu可執行的程式碼。

本篇文章限制下兩個名詞的定義:

● 材質:特指在虛幻引擎材質編輯介面透過連線等方式,生成的材質資源,它是藍圖系統的產物。材質資源會被轉成hlsl程式碼,鑲嵌進引擎側的hlsl程式碼中,才能形成完整的著色器程式碼;

Shader:特指以著色語言為基礎實現的著色器程式碼,包括vs、ps、cs。

我們要怎麼看待材質?

美學的具象:往往美術對美的表述是簡單而抽象的,例如空氣感、太單薄等等,能從自己的經驗池中剝離出符合的畫面並表達出來的難度是很大的。所以,我們經常也會走另外一條路徑,找美術要參考畫面,或者有材質基礎的美術先將畫面表達出來後,技術參與後續的效能最佳化;

技術的集大成:即使相同的效果,不同專業職能的同學,做出來的效率差異會非常顯著。每一個獨特的材質效果,都是一個獨立的技術方案,不一定遜色於米爾散射、球諧光照等等專業術語的。既然是一個技術方案,就涉及很多小的技術策略的選擇,有哪些演算法,為什麼我選擇它,它有什麼優點,就是我們常規的技術正規化了。就以噪點函式為例,有perlin噪聲、低差異序列等等,哪個演算法更適合......。技術背後隱藏的決策是需要長期經驗的積累,可能會涉及GPU原理、程式設計思維、效能最佳化等等。

材質是在效能達標基礎上,滿足指定功能性的技術方案。我覺得它最重要的兩個特性是:

● 功能性

● 效能

有時候,我們為了提高材質的可讀性,透過函式封裝的形式將材質模組化。但是,要控制好模組化的粒度,過度模組化會帶來負作用的,反而降低可讀性和可維護性。如下圖所示,有一次去嘗試迭代最佳化一個過度模組化的材質,需要額外開啟七八個介面,腦海中還要串聯它們之間的關係時,就感覺腦子暈暈的,模組化也不是那麼的美好。

材質的效能包括3個方面,後面會從這三個方面進行詳細分析:

儲存開銷

● 記憶體佔用

● 執行開銷

材質人力不足是常態,為了滿足不斷增漲的表現訴求,美術、特效、程式都成為了材質的主力,限制他們的提交是不現實的,我們只做了一點:

● 材質目錄管控:標準材質庫由專人提交維護,限制臨時材質可提交目錄,避免材質的無序堆放;希望隨著後續人力資源到位,逐步梳理臨時材質,遷入正式的標準材質庫;

當材質人力嚴重不足時,問題處理起來也挺簡單的,我們裝死就好了,只要材質不是太過分,就假裝什麼都不知道。但是,隨著我們產品拿到版本,逐步補充人才進組後,我就在思考這批材質要怎麼最佳化、規範是什麼、如何監控,像之前那種靠人眼逐個排查材質的思路,是行不通。於是,我開始了總結覆盤,我們做了哪些,還缺少哪些,我們要達到什麼樣的目標。也許,我們需的是一套自動化的材質檢測流程。

● 材質規範,持續完善;

● 監控機制,及時發現效能瓶頸;

● 技術策略,提供效能分析資料,最佳化材質系統,減少材質製作使用的效能浪費。

儲存開銷

1

基礎變體

怎麼理解材質與shader的關係呢?如下圖所示,每一條路徑,都能生成一個獨立的shader,因此,每個材質,就能離線生成多個shader。

有兩個維度:

1)Usage:定義了基礎了頂點格式,表示這個材質會用在哪裡,例如粒子系統,或者骨骼模型等等,材質上可以透過開關來控制;

2)Lighting Policy:引擎層根據場景的表現需求,選擇合理的光照方案,例如有的模型選擇Lightmap,有的模型選擇ILC,有的模型受到陰影投影等等。原生引擎的光照策略存在冗餘,所以可以根據自身產品的光照方案,做極致的變體最佳化,可以顯著降低shader儲存,這是引擎最佳化內容。通常,有光材質的變體量比無光模式會多出幾倍。

兩個維度相乘,表示這個材質能輸出的shader總數,我們稱之為基礎變體數。例如一個材質勾選了“Used with Skeletal Mesh”,“Used with Particle Sprites”,“Used with Beam Trails”,引擎層的光照模式是8,那麼這個材質的基礎變體數是3 * 8。

影響材質基礎變體數的因素有4個:

● Usage

● 引擎層的光照模式數

● Blend Mode

● Shading Model,Default Lit/Unlit

2

例項變體

一個材質,可以生成多個材質例項(Material Instance),通常場景中實際用的是材質例項。

一個材質包括多個功能,這些功能透過靜態開關的區分開來。如果材質中定義了4個靜態開關,最終就按照2 ^ 4 = 16個例項變體來儲存,就顯得非常的愚蠢。因此,引擎會根據材質例項的實際使用情況,來輸出最終的shader。我們稱由靜態開關產生的shader為例項變體


虛幻引擎支援材質例項的屬性覆蓋功能,如下圖所示。它會導致材質生成的shader發生變化,產生新的材質變體。特別注意,如果材質的ShadingModel是Unlit,材質例項覆蓋屬性變成Default後,會導致基礎變體數增漲嚴重,所以儘量要將有光和無光材質拆分開來。

3

應用策略

分析材質的儲存時,需要把一個材質與它引用的所有材質例項作為一個單元來分析,變體儲存的計算公式就是:基礎變體數 * 例項變體數

至此,從材質層面分析了shader儲存的來源,接著透過一個案例來聊聊一些策略。

不同的特效同學實現相關性的功能,往往會重複複製相似的功能模組,就會產生材質的冗餘,材質冗餘存在兩個方面的問題:變體儲存和維護成本。那麼,就需要一套通用的基礎材質庫,增強複用性。

為了降低材質的冗餘性,我們期望設計一個通用母材質,開始,一切是非常美好的。隨著研發持續,特效、TA持續為這個母材質添磚加瓦,導致它成為了一個龐然大物,它派生的材質例項規模龐大。特效不經意多勾選幾個開關,修改幾個覆蓋屬性,就會導致變體存在大量冗餘,最終,一個材質能產生的變體量超過其它特效變體的總和。維護成本極高,不敢輕易的改動。

分析下這個材質在儲存效能:

● 材質的基礎變體數大,Usage勾選很多;

● 材質例項的屬性覆蓋功能無限制;

● 材質例項的靜態開關無規則限制。

我們的初衷是:設計一套複用性強的基礎材質庫,它不一定是一個單一的材質,可以是一組粒度合理的材質庫。

技術層面,擴充套件材質的功能性:

● 可以控制由它派生出的材質例項去覆蓋哪些屬性,例如禁用掉ShadingModel的屬性覆蓋;

● 可以透過規則控制靜態開關的排列組合,降低材質使用者誤操作導致的儲存增漲;

● 有材質變體的監控機制,例如單一母材質以及相關材質例項的變體監控。

材質實現層面,也有了更高的要求,有一些準則需要去遵循:

● 材質分類,控制材質功能的粒度,減少勾選Usage開關,限制靜態開關數量;

● 拆分有光和無光材質;

● 透過材質開關,限制材質例項的屬性覆蓋功能;

● 編寫靜態開關的排列規則,控制材質例項變體的無序增漲。

記憶體開銷

UE4.24載入shader時,存在一個缺陷,是載入高中低檔的材質,再根據需要,將沒用到檔位的材質解除安裝。這是一種很浪費的做法,因此需要補齊的功能是,僅載入需要用到檔位的材質。將UE4引擎中的STORE_ONLY_ACTIVE_SHADERMAPS宏程式碼開啟,再修復下遇到的BUG即可。

當材質引用一張貼圖A,即使材質例項用貼圖B覆蓋貼圖A,虛幻引擎載入材質例項時會同時快取貼圖A和貼圖B,這是一種記憶體的浪費,從技術手段規避難度風險都比較高。我們把壓力給到了製作,材質製作層面的規範:

● 材質引用的貼圖必需要小貼圖,保證128以內;

● 材質例項的父類必需是材質,禁止材質例項的迴圈巢狀。

技術層面,我們補齊自動修復工具和監控機制:

● 自動化篩查材質引用大貼圖報警機制;

● 一鍵打破迴圈巢狀的修復工具

● 禁用材質例項作為父類的例項建立方式。

執行開銷

1

SIMD VS SIMT


早期的移動GPU採用的是純SIMD架構(single instruction, multiple data),每條指令運算單位是vec4。在SIMD架構下執行一個vec3的計算,它無法充分利用GPU的算力,存在一個32bit的算力浪費。

接著,移動GPU採用了SIMT(single instruction, multiple thread),改成標量架構,每個執行緒執行的資料單位是32bit,這樣子,執行一個vec3的計算,就排程3個執行緒執行,不存在浪費。在這種標量架構下,一個運算單位是32bit,一個半精度是16bit,GPU會利用SIMD命令,將兩個半精度填充進一個運算單元內,提高執行效率。

以arm的[valhall架構]https://developer.arm.com/documentation/102203/0100/Valhall-shader-core%20)為例,GPU不是每個時鐘跑一個32bit的執行緒,它會把多個執行緒分成一組(warp)來執行,valhall採用的是16bit寬的warp,Arm稱之為warp-based vectorization scheme。例如,我們執行vec3的指令運算,那麼3個執行緒會被分到同一組內,獲得一定的效能收益。

For of a single thread, this architecture looks like a stream of scalar 32-bit operations. This means that achieving high utilization of the hardware is a relative straight forward task for the shader compiler. However, the underlying hardware keeps the efficiency benefit of being a vector unit with a single set of control logic. This unit can be amortized over all of the threads in the warp.

Valhall maintains native support for int8, int16, and fp16 data types. These data types can be packed using SIMD instructions to fill each 32-bit data processing lane. This arrangement maintains the power efficiency and performance that is provided by the types that are narrower than 32-bits.

由此,得出shader最佳化建議:

● 儘可能使用半精度型別
● 儘可能標量跟標量運算

/*

 * Definition:

 *  vec3 A, B;

 *  float a0, b0, a1, b1, a2, b2;

*/

// 不推薦寫法

vec3 c0 = A * a0 * a1 * a2;

vec3 c1 = B * b1 * b1 * b2;

vec3 c2 = c0 * c1;

OutColor = vec4(c2, 1.0);



// 推薦寫法,儘可能標量跟標量運算

float a = a0 * a1 * a2;

float b = b1 * b1 * b2;

vec3 c0 = A * a;

vec3 c1 = B * b;

vec3 c2 = c0 * c1;

OutColor = vec4(c2, 1.0);



● 儘可能將多個標量pack成向量進行運算



/*

 * Definition:

 *  float a0, b0, a1, b1, a2, b2;

*/

// 不推薦寫法

float c0 = a0 * b0;

float c1 = a1 * b1;

float c2 = a2 * b2;

OutColor = vec4(c0, c1, c2, 1.0);



// 推薦寫法,儘可能將多個標量pack成向量進行運算

vec3 a = vec3(a0, a1, a2);

vec3 b = vec3(b0, b1, b2);

vec3 c = a * b;

OutColor = vec4(c, 1.0);

● 儘可能將多個標量pack成向量進行運算

/*

 * Definition:

 *  float a0, b0, a1, b1, a2, b2;

*/

// 不推薦寫法

float c0 = a0 * b0;

float c1 = a1 * b1;

float c2 = a2 * b2;

OutColor = vec4(c0, c1, c2, 1.0);



// 推薦寫法,儘可能將多個標量pack成向量進行運算

vec3 a = vec3(a0, a1, a2);

vec3 b = vec3(b0, b1, b2);

vec3 c = a * b;

OutColor = vec4(c, 1.0);

此外,GPU load/store資料時,也是以向量資料為主。在標量架構下,標量pack為向量,依然是一種有效的最佳化手段。

2

分支指令

GPU執行運算的基礎單元是warp,同一個warp內必需執行相同的程式碼。當處理器發現執行緒路徑不一致時,它會禁用一些執行緒並在一條路徑上執行指令,然後禁用其他執行緒並執行其他路徑中的指令,條件判斷會降低GPU的效能。

效能由好到差的三類條件判斷語句,如下所示:

● 常數,編譯開銷

● uniform變數

● shader內的動態變數

如果uniform變數是一個陣列元素,它作為條件判定的條件,開銷也不低。對於一些簡單的語句,我們推薦把語句展開:

● a > b ? 1.0 : -1.0,可讀性高,推薦使用

● min

● max

● clamp

● step

3

Varying單元

GPU中有一個獨立的硬體模組稱為varying unit,用於一些由vs傳出至ps的資料的插值。

例如,我們會在vs中計算好UV座標,傳到ps中進行紋理取樣,ps中的UV座標是不同頂點計算出的結果插值出來,插值計算就需要一個獨立的運算單元來處理。

插值單元是一個32bit的資料運算,16bit的運算會比32bit快

兩倍。推薦儘可能使用medium(16bit)型別的資料,將多個float資料封裝成vec2或vec4,也會有更高的效率,例如vec4就比vec3 + float的組織形式高效。

4

貼圖取樣

arm的gpu上明確,貼圖格式會影響取樣的效率,adreno和apple不定:

● FP32,2x cost

● 3D format, 2x cost

● Depth formats, 2x cost for Utgard or Midgard, 1x on Bifrost or Valhall. But, if there is a reference or comparison depth, then it is a 2x cost, for example Shadow Maps.

● Cubemap, 1x cost per cube face.

● YUV formats, Nx cost for older than Utgard, 1x cost for Bifrost or Valhall.

案例1,在mali gpu上,基於此來分析下陰影PCF的效能。硬體PCF 2x2,需要4次取樣,它的開銷是8x;軟體PCF 2x2,9次取樣,它的開銷是9x,似乎差異就沒那麼顯著了。

案例2,在實現GTAO演算法時,需要儲存兩個float資料到R16G16F貼圖中,FP32格式的取樣開銷是2x,就可以將這兩個float按照編碼演算法壓縮至RGBA8中,unorm格式的取樣開銷是1x,從而達到降低貼圖取樣開銷的目的。

現在的手遊產品基本都會採用astc壓縮,減少頻寬和包體儲存;利用硬體sRGB,避免軟解開銷。

影響貼圖取樣的另外一個維度是濾波方式,trilinear和anisotropic對頻寬也能產生一定影響:

● point/bilinear,1x

● trilinear,2x

● anisotropic,Nx,N依賴於最大支援的層數以及實際的視角等

推薦儘量:

● 合理的貼圖濾波方式

● 合理的貼圖格式

● 限制取樣的貼圖數和單個貼圖取樣個數

● 避免跨度太大的隨機取樣,鄰近取樣可以提高快取命中率

特別解釋下“跨度太大的隨機取樣”這個點,它比較容易被忽視。GPU中存在一個稱為L1/L2 Cahce的硬體單元,它們的存在,會降低多次鄰近取樣的效能開銷。跨度太大的隨機取樣,降低的就是快取的命中率,它會嚴重降低GPU效能,特別是頻寬開銷。舉個例子,寫一個簡單的全屏後處理演算法,全螢幕隨機取樣,可以發現後處理演算法的頻寬能上漲n G/sec,這在移動平臺是不可能接受的。

此外,為了方便分析演算法的效能,adreno官方提供了一個簡化的取樣開銷與指令數的比率為:

● A5X GPU,16 :1

gpu效能越高,取樣的開銷會越低,因此在中高檔裝置用LUT快取複雜的計算的思路也成為了可能性,例如,選擇tonemap演算法時,我們可以生成lut後快取起來,後續只需要取樣這張貼圖即可,從而避免複雜的filmic tonemap、color grading、white balance的計算開銷。但是,除非必要情況,並不推薦用貼圖快取計算的思路。

5

指令計算

指令計算有一些基礎的特性:

● 加減法、乘法、除法,效能開銷逐步增加;

● MAD形式表示為a * x + b,會被解析為一條指令運算。通常,在spirv底層解析不會顯示替換FMA,它有比較嚴重的相容性問題,全靠驅動的自覺性;

● 反三角函式的計算開銷比較高。

我們推薦儘量:

● 儘量減少指令數

● 儘量使用MAD形式

● 儘量避免使用反三角函式等,可以用簡單的演算法進行擬合,例如

float atan2Fast( float y, float x )

{

float t0 = max( abs(x), abs(y) );

float t1 = min( abs(x), abs(y) );

float t3 = t1 / t0;

float t4 = t3 * t3;



// Same polynomial as atanFastPos

t0 =         + 0.0872929;

t0 = t0 * t4 - 0.301895;

t0 = t0 * t4 + 1.0;

t3 = t0 * t3;



t3 = abs(y) > abs(x) ? (0.5 * PI) - t3 : t3;

t3 = x < 0 ? PI - t3 : t3;

t3 = y < 0 ? -t3 : t3;



return t3;

}

利用CPU離線計算,透過uniform變數傳給shader使用。例如,指令x/a,可以提前在CPU中計算1/a,shader中只需要一個乘法就可以。舉兩個案例說明下核心思路。

案例1,white balance的指令最佳化,我們發現,綠框內的計算只需在cpu計算一次即可,透過uniform變數傳給GPU,指令數能從原來的110條降低至3條。

案例2,color correction的指令最佳化,可以對公式進行變形,綠框內的計算可以放到cpu計算,整個演算法的指令數能由57條降低至27條。

類似的,可以將一些運算放在VS中,透過varying,傳給PS,因為VS的運算量是遠低於PS。比如說,很多後處理的UV座標,甚至於一些GPU裝置會對這類UV取樣進行最佳化。還比如說,在UE4引擎的移動管線中,指數霧就放在VS中計算。

在shader中,由於乘法的開銷會低於除法,所以對於x / a這類運算,可以先將1/a計算後快取起來,後續重複使用。

對於迴圈語句,當迴圈次數確定且運算不復雜,我們推薦將迴圈展開,可以減少暫存器佔用,提高效能。

// 簡單的迴圈函式,就推薦展開形式

for (i = 0; i < 4; ++i) {

    diffuse += ComputeDiffuseContribution(normal, light[i]);

}



// 推薦寫法

diffuse += ComputeDiffuseContribution(normal, light[0]);

diffuse += ComputeDiffuseContribution(normal, light[1]);

diffuse += ComputeDiffuseContribution(normal, light[2]);

diffuse += ComputeDiffuseContribution(normal, light[3]);

儘量避免一些特殊的語義對效能產生副作用,包括:

● alpha to coverage

● discard

● 片斷著色器中寫gl_FragDepth

這些語義會導致Early-ZS和HSR失效,對效能產生較大的負作用。

儘量避免隱式的型別轉換。

// 貼圖取樣的資料是vec4,就存在隱式的型別轉換,需要額外的指令槽位(Instruction Slot)。



uniform sampler2D ColorTexture;

in vec2 TexC;

vec3light(in vec3 amb, in vec3 diff)

{

vec3 Color = texture(ColorTexture, TexC);

    Color *= diff + amb;

return Color;

}



// 推薦寫法

uniform sampler2D ColorTexture;

in vec2 TexC;

vec4 light(in vec4 amb, in vec4 diff)

{

  vec4 Color = texture(Color, TexC);

    Color *= diff + amb;

return Color;

}

要注意變數的資料型別,避免隱式轉換。

// 反面例子,1.0是單精度,會導致編譯器需要轉成int型別

int4 ResultOfA(int4 a) 

{

return a + 1.0;

}

要注意變數的資料型別,避免隱式轉換。

在一些ARM的GPU上,還有一些特殊的要求,要避免不完整的初始化,compiler不能支援未完整初始化的vec向量,會造成load/store bound,影響效能。如下所示,這個shader中z、w分量未初始化,gpu compiler不能確定這兩個分量的值,因此在渲染每個畫素時,都要複製這個變數到對應的thread中,造成讀寫頻寬異常變大幀率下降比較明顯。

vec4 TmpArray0;

TmpArray0.xy = vec2(0, 1)

儘量避免在VS中取樣貼圖紋理,雖然現在的移動硬體都是用統一shader架構,但是一些硬體廠商會對VS做一些最佳化,不會引入貼圖單元的資料處理流。若在VS中取樣貼圖,就會導致GPU對VS的最佳化失效。

ubo變數有很高的訪問效率,優於tbo和ssbo,但是需要控制它的尺寸,儘量控制在8k以內。

UE輸出的shader是基於hlsl,需要經過shader conductor解析模組,才能生成最終GPU可識別的著色程式碼。shader conductor的技術特點,會影響上層hlsl程式碼的書寫規範。

避免"+="的寫法。

// 反面教材

half4 Color = 0;

Color += BloomDownSourceTexture.Sample(BloomDownSourceSampler, InUVs[0].xy) * 4.0;

Color += BloomDownSourceTexture.Sample(BloomDownSourceSampler, InUVs[1].xy);

OutColor = half4(Color.xyz, 0.0);



// 解析後的語句

highp vec4 _39 = vec4(vec4(0.0));

vec4 _41 = vec4(_39 + (texture(BloomDownSource, in_var_TEXCOORD0[0].xy) * 4.0));

highp vec4 _45 = vec4(_41);

vec4 _47 = vec4(_45 + texture(BloomDownSource, in_var_TEXCOORD0[1].xy));

out_var_SV_Target0 = vec4(_47.xyz, 0.0);



// 推薦寫法



half3 N0 = BloomDownSourceTexture.Sample(BloomDownSourceSampler, InUVs[0].xy).xyz;

half3 N1 = BloomDownSourceTexture.Sample(BloomDownSourceSampler, InUVs[1].xy).xyz;

OutColor = half4(N0 + N1, 0.0);


// 解析後的語句



vec3 _40 = vec3(texture(BloomDownSource, in_var_TEXCOORD0[0].xy).xyz);

vec3 _45 = vec3(texture(BloomDownSource, in_var_TEXCOORD0[1].xy).xyz);

out_var_SV_Target0 = vec4(_40 + _45, 0.0);

定義常量時,避免帶“.f”字尾,shader conductor會把帶有.f字尾的常數解析為全精度,例如1.0f。

避免定義一個向量型別的變數,再對向量中的分量獨立賦值的寫法。

// 反面教材



half2 dir;

dir.x = dirSwMinusNe + dirSeMinusNw;

dir.y = dirSwMinusNe - dirSeMinusNw;



// 解析後的語句

vec2 _125 = _52;

_125.x = _117 + _119;

vec2 _127 = _125;

_127.y = _117 - _119;



// 推薦寫法

half2 dir = half2(dirSwMinusNe + dirSeMinusNw, dirSwMinusNe - dirSeMinusNw);



// 解析後的語句



vec2 _126 = vec2(_116 + _118, _116 - _118);

語句x / a,a是一個常量時,推薦改成x * (1 / a),shader conductor解析後,會將1/a解析成一個常數,這條指令就由除法最佳化為乘法。

複雜的常數運算,需要自己計算好結果,shader conductor沒有理想中的那麼智慧。

// Constants for DOF blend in.

half CocMaxRadiusInPixelsRcp() 

{ 

half2 MaxOffset = half2(-2.125,-0.50)*2.0; 

returnrcp(sqrt(dot(MaxOffset, MaxOffset))); 

}



half2 CocBlendScaleBiasFine() 

{

half Start = 0.0 * CocMaxRadiusInPixelsRcp();

half End = 0.5 * CocMaxRadiusInPixelsRcp();

half2 ScaleBias;

ScaleBias.x = 1.0/(End-Start);

ScaleBias.y = (-Start)*ScaleBias.x;

return ScaleBias;

}



// half2 ScaleBias2 = CocBlendScaleBiasFine(); // Constant.

static consthalf2 ScaleBias2 = half2(8.73212433, 0);

6

半精度

儘量提高半精度的佔比是shader最佳化的重要目標,怎麼用好半精度是一個很大的話題。

引擎的指令解析層面,需要對支援好半精度相關的解析,例如:

● 支援解析半精度指令;

● 支援解析半精度ubo,metal是半精度,vulkan/glsl是偽半精度;

● 精度型別對齊,不會出現半精度 * 全精度的情況,spirv除外。

下面我來聊一聊應用層面能怎麼做。

半精度格式

符點數格式遵循IEEE 754標準,單精度32bit,23bit有效位,8bit偏置指數位,1bit符號位;半精度16bit,10bit有效位,5bit偏置指數位,1 bit符號位。用十進位制為例來簡單說明精度格式的原理,例如想表示數值1532.2,有效位的作用是儲存15322,偏置指數位的作用是這個小數點要放在哪個位置,符號位表示這個數值是正數還是負數。

對於絕大部分同學而言,我們不需要去深究其背後的原理,但是必需牢記半精度格式的兩個特性:

● 表示的數值有限,最大隻能表示65504,記住這個數字;

● 有效位只有10bit,數值越大,誤差越大。例如,1~10,可能誤差是0.001左右;但是5000~10000,誤差可能就在1左右。

我們通常可以看到如下所示的類似取樣UV的計算,由於數值越大,小數部分的誤差會越來越大,最終導致渲染出現馬賽克。

half2 TexCoord = frac(Time * Tiling);

由於半精度表示的數值有限,通常座標運算不能用半精度運算。取樣貼圖用的座標用半精度計算的話,有一定風險,不一定有問題。

半精度問題產生的渲染異常,通常是馬賽克、噪點等等。由於驅動的相容性異常,甚至會導致某些裝置閃退。

怎麼用

虛幻引擎中,半精度型別用half表示,全精度型別用float表示,挺容易理解的。

注意避免全精度汙染問題,非常重要!透過新增型別強轉,可以保證解析後的指令是半精度。

// 反面教材

// 只要運算指令中存在全精度,整個語句都會按照全精度解析;

/*

 * float A;

 * half B, C, D;

*/

half F = A * C * D * B;



// 推薦寫法

half F = half(A) * C * D * B;

當存在uniform變數時,往往會遺忘全精度汙染問題。

// 反面教材

float4 GammaAndAlphaValues;

half4 main(VertexToPixelInterpolants VIn) : SV_Target0

{

half4 OutColor = SceneColorTexture.Sample(SceneColorTextureSampler, VIn.TexCoords.xy);

OutColor = pow(OutColor, GammaAndAlphaValues.x);

return OutColor;

}



// 推薦寫法

float4 GammaAndAlphaValues;

half4 main(VertexToPixelInterpolants VIn) : SV_Target0

{

half4 OutColor = SceneColorTexture.Sample(SceneColorTextureSampler, VIn.TexCoords.xy);

OutColor = pow(OutColor, half(GammaAndAlphaValues.x));

return OutColor;

}

對於半精度ubo變數,認為有半精度修飾,就可以不用轉型強轉,其實這是錯誤的思路。因為spirv和msl支援半精度ubo,但是glsl不支援半精度ubo的解析,因此不管uniform是否有半精度修飾,必需把它們作為全精度看待。shader conductor指令解析,會將冗餘的型別強轉最佳化掉。

再聊聊材質層面的使用建議。材質上有兩個功能單元:

● 可變引數,引擎會將它翻譯成uniform變數;

● 運算子,引擎會根據它的連線情況,翻譯成半精度指令。

如下圖所示的例子,像這樣的藍圖連線,引擎生成的HLSL的程式碼如下所示,這存在全精度汙染問題。

// Material_VectorExpressions是uinform

half4 Local0 = ProcessMaterialColorTextureLookup(Texture2DSample(Material_Texture2D_0, Material_Texture2D_0Sampler, Parameters.TexCoords[0].xy));

half Local1 = Material_VectorExpressions[1].rgb * Local0.r;

材質層面普遍存在全精度汙染問題。技術最佳化手段是,在材質藍圖生成HLSL程式碼時,針對材質的uniform變數,顯式增加一個半精度型別轉換。

half4 Local0 = ProcessMaterialColorTextureLookup(Texture2DSample(Material_Texture2D_0, Material_Texture2D_0Sampler, Parameters.TexCoords[0].xy));

half Local1 = half3(Material_VectorExpressions[1].rgb) * Local0.r;

怎麼除錯

我們經常能遇到半精度引起的渲染異常,因為編輯器上不支援半精度,只有移動平臺支援,材質的製作和使用者不能即時的發現問題。如果發現異常的情況,可以用Render Doc截幀除錯。以vulkan為例,簡單說明下除錯方法。

1)啟動遊戲,找到異常的drawcall,選中FS,點選Edit後的下拉框,會有3個選項:Decompile with SPRIV-Cross,spirv會轉換成glsl呈現出來,可讀性相對較高;Decompile with spirv-dis,spirv的文字形式。glsl是render doc由spirv翻譯來的,spirv是GPU執行的真實程式碼。偶爾會遇到glsl表現正常,但是spirv表現異常的情況。

2)修改原始碼,點選“Apply changes”,可以即時看到渲染結果。為了快速排查是否是半精度導致的異常,可以將spirv內所有帶RelaxedPrecision的語句刪除或者將glsl內所有的mediump修飾符刪除。

7

監控機制

離線分析shader的效能工具有adreno offline compiler和mali offline compiler,我們可以在材質編輯器上整合這些工具,將指令數和取樣數呈現出來。

我們想要的是什麼:

● 監控整體的材質效能資料;

● 及時發現開銷過高的材質。

經過前面的分析,材質方面有多個特點:

● 一個材質及其例項,會涵蓋大量的變體,選擇單一型別變體不具備普適性;

● 引擎層面的改造,影響材質層面的效能監控。

技術策略:

● 指令數資料要落實到每個變體,放到cook生成的變體csv表中;

● 增加基準材質的指令數識別,透過差值就可以統計出材質層面的指令現狀,排除引擎層面的影響;

● 增加週期性的效能資料呈現;

● 持續完善檢測報警機制,透過自動化的手段,更早的發現並解決問題。例如,我們發現引擎材質內建的Noise函式效能開銷極高,那麼我們就可以增加一條監測規則,用上Noise函式的材質就報警。

https://mp.weixin.qq.com/s/vuoRsRKWNx0AzA6gupM8Zw

相關文章