1. 程式人生 > 實用技巧 >繪製地圖基礎元素-線(下篇)

繪製地圖基礎元素-線(下篇)

繪製地圖基礎元素-線(上篇)

前言

上篇中記錄了繪製線的基本流程,而下篇主要是對繪製線中遇到的效能和效果問題進行闡述。在繪製完一條線並且希望給其加上描邊樣式時,會遇到不可避免的閃爍問題。而在繪製大量的交錯道路時,需要同時考慮繪製效能和閃爍問題如何解決。本文總結了高效繪製描邊線的方法,並對調研過的解決Z-Fighting閃爍的方案進行闡述。

畫素圓角渲染的效能優化

在上篇中介紹了逐畫素剔除產生圓角的方法,概括的來說,為了達到動態圓滑的目的,將原來CPU中的數學計算移入了片元著色器中進行。這樣做雖然能得到最圓滑的效果,卻也給GPU帶來了壓力。以圓角線帽程式碼為例,受GPU處理方式影響,動態分支的if/else指令需要被全部執行,同時discard指令也會影響GPU的Early Z優化,二者都會對效能產生影響。

fixed4 frag (v2f i) : SV_Target
{
    if(i.geometryInfo.x < 0)  // 起點側線帽
    {    
        if(dot(float2(i.geometryInfo.x, i.geometryInfo.y), float2(i.geometryInfo.x, i.geometryInfo.y)) > 1)
        {   
            discard; // 距離圓心距離大於1則剔除
        }
    } 
    else if(i.geometryInfo.x > 1) // 終點側線帽
    {
        if(dot(float2(i.geometryInfo.x - 1, i.geometryInfo.y), float2(i.geometryInfo.x - 1, i.geometryInfo.y)) > 1)
        {   
            discard; 
        }
    }

    return i.color;
 }

因此在片元著色器中指令的效能優化上,主要是將其邏輯改為線性,移除動態分支,並以Alpha Blending代替discard。簡化流程的主要工具是CG標準函式step/clamp/lerp,其定義如下,靈活運用這些函式就可以規避動態分支。

簡化流程後的片元著色器程式碼如下,通過消除動態分支語句和discard指令減少效能開銷,犧牲部分程式碼的可讀性,但提升了並行效率。其中為了確定畫素是否屬於線帽構造了二次函式,實際上也可以構造其他型別的函式達到目的。

fixed4 frag (v2f i) : SV_Target
{
    fixed4 clearColor = 0;
    fixed  isClear = 0;
	
    fixed origin = clamp(i.geometryInfo.z, 0 ,1);  // 兩側線帽x值收縮到0和1
    fixed4 isCap = step(0, origin * (origin - 1)); // 構建二值函式,線帽為1,線段為0
    fixed2 dist = fixed2(i.geometryInfo.z - origin, i.geometryInfo.w); // 構建距離向量
    isClear = step(1, dot(dist, dist)) * isCap; // 距離小於1(不需要剔除)為0,距離大於等於1(需要剔除)且是線帽畫素,則為1
	
    return lerp(i.color, clearColor, isClear);
 }

繪製線的描邊

根據上篇完成一條線的繪製後,為了使線易於觀察,通常需要使得線具有描邊樣式。實際上,上篇中展示的線已經為了美觀都帶上了描邊,但要讓線有描邊部分還需要進行額外的繪製。

為了減少頂點數增加並簡化三角剖分的計算,通常是在繪製的填充線之下使用描邊線寬進行一次同樣的擴充套件繪製,描邊線寬構造產生的面更大,使得兩個線構成的面疊加展示就可以達到線描邊的效果。這種方案的描邊寬度為(sideLineWidth - lineWidth) / 2 。

描邊線的基本原理如上所述,而在實際的繪製中可以針對填充線和描邊線的特性,對渲染邏輯進行優化。在實踐中主要進行了以下探索:

1、提取變化點

可以看到描邊線和填充線在繪製時的擴充套件方向是一樣的,差別在於根據擴充套件向量擴充套件的線寬不同。因此可以將擴充頂點的計算抽離到頂點著色器中並行進行,資料處理時只計算擴充的基準向量,將其和線寬資訊藉助uv結構一同傳入shader中,這樣兩部分的線就可以複用同一個Shader進行渲染。但兩部分的線仍需要分兩次進行繪製,消耗兩個Draw Call。

2、從資料上改進為一個Draw Call呼叫

基於頂點著色器的思考,兩個線的繪製只有頂點位置和顏色的不同,因此可以模擬Batching操作,將兩條線的mesh資料進行合併,就可以在一個Draw Call呼叫進行繪製。可以看到,在兩個mesh的合併過程中只需要對三角形索引根據頂點數進行調整,其餘的資料都可以直接合並。

public LineMesh CombineLineMesh(LineMesh appendMesh)
{
    int index = this.vertices.Count;
    for (int i = 0; i < appendMesh.triangles.Count; ++i)
    {
        appendMesh.triangles[i] += index;
    }

    this.triangles.AddRange(appendMesh.triangles);
    this.vertices.AddRange(appendMesh.vertices);
    this.color32s.AddRange(appendMesh.color32s);
    this.geometrys.AddRange(appendMesh.geometrys);
    this.parameters.AddRange(appendMesh.parameters);

    return this;
}

3、從繪製方式上改進為一個Draw Call呼叫

雖然探索2中已經達到了一個Draw Call進行渲染,但是描邊線和填充線是使用兩組頂點進行的渲染,本著能省則省的精神,為了減少頂點數,可以考慮在一組頂點中,根據描邊線寬和填充線寬的比例資訊,一次性繪製出整個線。這種做法需要利用上篇文章中為了繪製圓角引入的geometry資訊,x資訊可以標識長度,而y值就可以作為寬度方向上的標識。若定義ratio為線寬的比值,則可根據片元著色器中y值的分佈確定渲染顏色。

ratio = lineWidth / sideLineWidth
abs(y)∈[0,ratio] -> color
abs(y)∈(ratio,1] -> sideColor

這個方案可以只使用一組頂點繪製完描邊線,但也存在一些問題:

1、線上帽和拐角的圓角支援上需要類似同心圓的繪製邏輯,需要再引入額外的條件判斷,對邏輯複雜度和效能都有影響。

2、在繪製大量相互交錯的線時,線的壓蓋順序需要動態的去調整,會遇到一部分交錯線的所有填充部分要壓蓋所有描邊部分,而一次性繪製的線是無法支撐這一效果的。

綜上,從繪製方式上的改進有其侷限性,探索2的繪製方式更為合適。

解決閃爍Z-fighting問題

繪製方案確定以後,在繪製時遇到的下一個問題就是線的Z-fighting問題,即觀察時線一直在閃爍。其原因是描邊線和填充線重疊部分所在的世界座標完全一致,座標轉換後受深度緩衝精度影響導致片元在渲染時無序通過深度檢測,最終表現為面的閃爍問題。

Z-fighting問題算是繪製線的最後一個障礙,其中涉及許多圖形學的基礎知識,在探索解決方案的過程中也對渲染的全流程有了更多的認識,探索的方案總結如下:

1、調整頂點的世界座標

解決Z-fighting問題的第一步是定位出深度值衝突的物件。在繪製帶描邊的線這個場景中,導致閃爍的原因是描邊線和填充線的重疊部分世界座標高度值一致,導致座標轉換後片元深度值一致。因此可以在衝突的面的高度值上增加一點兒偏移,通過改變區域性座標影響轉換後的深度值,最終可以看到閃爍現象消失。

根據前面的討論,修改區域性座標的操作可以放在Shader中並行進行,以Unity為例,通過設定一個priority變數用於微調頂點y方向的偏移,從而控制顯示的優先順序。

fillLineMesh.priority = 1;

v2f vert (a2v v)
{
    v2f o;
    float4 pos = v.vertex + float4(v.parameter.x, 0, v.parameter.y, 0) * v.parameter.z; // 根據向量和線寬計算實際頂點位置
    pos += float4(0, priority / 100, 0, 0); // 頂點y方向進行微調,需要把握微調大小
    
    o.pos = UnityObjectToClipPos(pos); 
    o.color = v.color;
    o.geometry = v.geometry;

    return o;
}

這種方式能暫時解決閃爍問題,但在將攝像頭位置拉遠後仍會出現。其原因是深度緩衝的精度有限,因此距離攝像頭越遠需要的偏移量越大,微調的偏移量需要根據頂點和攝像頭的距離動態調控。在實際操作中,視線方向與頂點微調方向多數情況下並不相同,而在解決大量線重疊的Z-fighting時,大量偏移的累加可能會從視覺上觀察到線不共面,與所有線在同一平面的地圖展示方式不符,因此方案一通常僅作為初步驗證Z-fighting原因的工具。

2、使用Offset指令

Unity ShaderLab提供了微調偏移的Offset指令,指令定義和計算公式如下:

Offset Factor, Units
offset = m * factor + r * units

其中m是由系統計算出的多邊形深度斜率的最大值,多邊形越是與近裁剪面平行,m就越接近於0,r是深度值可分辨的最小單位,是由系統指定的常量。若多邊形與裁剪面平行,則可以使用factor=0,units=1的組合控制偏移,而對於與裁剪面有夾角的多邊形,需要factor一同控制偏移量的大小,Offset結果大於0會使得多邊形遠離近裁剪面進行偏移,具體的引數值需要實踐過程中進行摸索確認。

使用Offset指令作用於裁剪空間的深度值可以解決多個Object之間的Z-fighting問題,但當為了減少Draw Call將所有線合併為一個mesh後就無法使用了,因此需要藉助於其原理手動調控同一mesh中不同線的深度資訊。

3、調整頂點的裁剪座標

深度資訊是在片元著色器之後計算得到的,因此無法通過著色器的可程式設計部分直接更改。但深度資訊是由裁剪空間的齊次座標計算而來,因此可以通過操控裁剪空間座標達到調整深度的目的。

在光柵化之前,座標會進行模型-檢視-投影變換由區域性座標轉換為裁剪座標,其中由觀察空間經由投影矩陣變換得到的就是裁剪空間齊次座標,其後轉換為螢幕空間得到的NDC座標z值由齊次座標的z/w得來,決定了深度值。由觀察空間座標轉換為裁剪座標需要以下引數:

f:遠裁剪面

n:近裁剪面

fov:視角

aspect:攝像機橫縱比

設觀察空間座標為 ,

則轉換到裁剪空間座標為:

根據深度值規則,在裁剪座標z值上新增-z*offset的偏移即可將深度向後微調offset大小。在UE4的material中,也可以通過調整Pixel Depth Offset達到偏移的效果。

v2f vert (a2v v)
{
    v2f o;
    o.pos = float4(UnityObjectToViewPos(float3(v.vertex.xyz)), 1.0);
    float z = o.pos.z;
    o.pos = mul(UNITY_MATRIX_P,  o.pos);
    o.pos.z = o.pos.z - z * v.parameter.z/1E8;// 使用parameter.z儲存頂點偏移資訊

    return o;
}

4、調整深度檢測

上述方案都是通過在不同的面之間構造微小偏移來解決Z-fighting問題,而另一種思路是不增加偏移,通過指定渲染時的壓蓋規則,先繪製的面被後繪製的面壓蓋,最終顯示出正確的影象。這種方案需要首先理解深度檢測的概念。

深度檢測在片元著色器之後進行,每個片元攜帶自身的深度值與深度緩衝內的深度值進行比較檢測,若檢測通過,深度緩衝內的值將被設為該深度值。若檢測失敗,則丟棄該片元。Unity ShaderLab使用ZWrite和ZTest兩個指令控制這一過程:

  • ZWrite控制檢測通過後,是否將片元深度寫入深度緩衝,預設開啟(ZWrite On)
  • ZTest定義深度值通過深度檢測的規則,預設是當片元深度值小於等於深度緩衝內的深度值時通過深度檢測(ZTest LEqual)
    在繪製二維地圖這一case中,不需要更改深度緩衝的寫入策略,只需要將深度檢測的策略改為全部通過即可:
ZWrite On
ZTest Always

小結

對於閃爍問題,前三個探索方案核心都是構造微小偏移,若fighting的面數過多,造成微小偏移大量疊加產生量變,可能會對圖形的透視顯示大小產生影響,這時推薦使用方案四。而對於多Object的情況,可以搭配方案二與方案四共同使用,效果更佳。

至此,已經解決了繪製線的所有問題,下圖使用各種純色進行了道路線繪製,如果效果不滿意,還可以嘗試進行紋理貼圖,使得道路線更加酷炫。

作者:程式設計師阿Tu

連結:https://zhuanlan.zhihu.com/p/266042561

來源:知乎

著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。