第二十章

高級 OpenGL • 深度測試

發布時間:2025-06-29 閱讀時間:23 分鐘

座標系統章節中,我們渲染了一個 3D 容器,並利用了 深度緩衝區 來防止三角形在應該位於其他三角形後面時卻渲染在前面。在本章中,我們將更詳細地闡述深度緩衝區(或稱 z-緩衝區)儲存的那些 深度值,以及它如何實際判斷一個片段是否在前面。

深度緩衝區是一個緩衝區,就像 顏色緩衝區(儲存所有片段顏色:視覺輸出)一樣,它為每個片段儲存資訊,並與顏色緩衝區具有相同的寬度和高度。深度緩衝區由視窗系統自動創建,並將其深度值儲存為 162432 位浮點數。在大多數系統中,你會看到一個精度為 24 位的深度緩衝區。

當啟用深度測試時,OpenGL 會將片段的深度值與深度緩衝區的內容進行測試。OpenGL 執行深度測試,如果測試通過,則渲染該片段,並使用新的深度值更新深度緩衝區。如果深度測試失敗,則該片段將被丟棄。

深度測試在片段著色器運行後(以及模板測試之後,我們將在下一章中討論)在螢幕空間中進行。螢幕空間座標與 OpenGL 的 glViewport 函數定義的視埠直接相關,並且可以通過 GLSL 內建的 gl_FragCoord 變數在片段著色器中訪問。gl_FragCoord 的 x 和 y 分量表示片段的螢幕空間座標((0,0) 為左下角)。gl_FragCoord 變數還包含一個 z 分量,其中包含片段的深度值。這個 z 值就是與深度緩衝區內容進行比較的值。

今日,大多數 GPU 支援一種稱為「早期深度測試」的硬體功能。早期深度測試允許在片段著色器運行之前執行深度測試。每當確定一個片段不會可見時(它位於其他物體後面),我們可以提前丟棄該片段。

片段著色器通常開銷相當大,因此我們應該盡可能避免運行它們。早期深度測試對片段著色器的一個限制是,你不應該寫入片段的深度值。如果片段著色器寫入其深度值,則無法進行早期深度測試;OpenGL 將無法提前確定深度值。

深度測試預設是禁用的,因此要啟用深度測試,我們需要使用 GL_DEPTH_TEST 選項來啟用它:

glEnable(GL_DEPTH_TEST);

啟用後,如果片段通過深度測試,OpenGL 會自動將其 z 值儲存在深度緩衝區中;如果片段未通過深度測試,則會相應地丟棄。如果啟用了深度測試,則應在每幀之前使用 GL_DEPTH_BUFFER_BIT 清除深度緩衝區;否則,你將會遇到上一幀的深度值:

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

在某些可想像的情境中,你希望對所有片段執行深度測試並相應地丟棄它們,但更新深度緩衝區。基本上,你(暫時)使用的是一個 唯讀 深度緩衝區。OpenGL 允許我們透過將其深度遮罩設定為 GL_FALSE 來禁用寫入深度緩衝區:

glDepthMask(GL_FALSE);

請注意,這僅在啟用深度測試時才有效。

深度測試功能

OpenGL 允許我們修改用於深度測試的比較運算符。這使我們能夠控制 OpenGL 何時應該通過或丟棄片段,以及何時更新深度緩衝區。我們可以通過呼叫 glDepthFunc 來設定比較運算符(或深度功能):

glDepthFunc(GL_LESS);

該函數接受多種比較運算符,如下表所示:

函式 (Function)描述 (Description)
GL_ALWAYS深度測試永遠通過。
GL_NEVER深度測試永遠不通過。
GL_LESS如果片段的深度值小於儲存的深度值,則通過。
GL_EQUAL如果片段的深度值等於儲存的深度值,則通過。
GL_LEQUAL如果片段的深度值小於或等於儲存的深度值,則通過。
GL_GREATER如果片段的深度值大於儲存的深度值,則通過。
GL_NOTEQUAL如果片段的深度值不等於儲存的深度值,則通過。
GL_GEQUAL如果片段的深度值大於或等於儲存的深度值,則通過。

預設情況下,使用深度函數 GL_LESS,它會丟棄所有深度值大於或等於當前深度緩衝區值的片段。

讓我們展示改變深度函數對視覺輸出的影響。我們將使用一個全新的程式碼設置,它顯示一個基本場景,其中有兩個紋理立方體位於一個紋理地板上,沒有光照。你可以在這裡找到原始碼。

在原始碼中,我們將深度函數更改為 GL_ALWAYS

glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_ALWAYS);

這模擬了我們不啟用深度測試時所會得到的相同行為。深度測試總是通過,因此最後繪製的片段會渲染在之前繪製的片段前面,即使它們應該在前面。由於我們最後繪製了地板平面,所以平面的片段會覆蓋之前寫入的每個容器片段:

將所有設定恢復為 GL_LESS 會讓我們回到我們習慣的場景類型:

深度值精度

深度緩衝區包含介於 0.01.0 之間的深度值,它將其內容與從觀察者角度看到的場景中所有物件的 z 值進行比較。視圖空間中的這些 z 值可以是投影錐體 平面和 平面之間的任何值。因此,我們需要一種方法將這些視圖空間的 z 值轉換到 [0,1] 範圍內,其中一種方法是線性轉換它們。以下(線性)方程式將 z 值轉換為介於 0.01.0 之間的深度值:

\begin{equation} F_{depth} = \frac{z - near}{far - near} \end{equation}

這裡的 $\text{near}$ 和 $\text{far}$ 是我們用來提供給投影矩陣以設定可視錐體的「近」和「遠」值(請參閱座標系統)。該方程式將錐體內的深度值 $z$ 轉換為 [0,1] 範圍。z 值與其對應深度值之間的關係如下圖所示:

請注意,所有方程式在物體靠近時會給出接近 0.0 的深度值,而在物體接近遠平面時會給出接近 1.0 的深度值。

然而,實際上,幾乎從不使用這樣的「線性深度緩衝區」。由於投影特性,會使用與 1/z 成比例的非線性深度方程式。結果是,當 z 值較小時,我們能獲得極高的精度;而當 z 值較遠時,精度則會大大降低。

由於非線性函數與 1/z 成比例,介於 1.02.0 之間的 z 值將產生介於 1.00.5 之間的深度值,這佔據了 [0,1] 範圍的一半,在 z 值較小時為我們提供了極高的精度。而介於 50.0100.0 之間的 z 值將僅佔 [0,1] 範圍的 2%。考慮到近距和遠距的這類方程式如下所示:

\begin{equation} F_{depth} = \frac{1/z - 1/near}{1/far - 1/near} \end{equation}

不必擔心你是否完全理解這個方程式的運作原理。重要的是要記住,深度緩衝區中的值在裁剪空間中不是線性的(它們在應用投影矩陣之前在視圖空間中是線性的)。深度緩衝區中 0.5 的值並不意味著像素的 z 值位於視錐體的一半;頂點的 z 值實際上非常接近近平面!你可以在下圖中看到 z 值與最終深度緩衝區值之間的非線性關係:

如你所見,深度值在很大程度上取決於較小的 z 值,這為我們提供了對近處物體的大量深度精度。將 z 值(從觀察者角度)轉換的方程式嵌入在投影矩陣中,因此當我們將頂點座標從視圖空間轉換到裁剪空間,然後再到螢幕空間時,非線性方程式就會被應用。

當我們嘗試視覺化深度緩衝區時,這種非線性方程式的效果很快就會變得明顯。

對深度緩衝區進行可視化

我們知道片段著色器中內建的 gl_FragCoord 向量的 z 值包含該特定片段的深度值。如果我們將片段的深度值輸出為顏色,我們就可以顯示場景中所有片段的深度值:

void main()
{
    FragColor = vec4(vec3(gl_FragCoord.z), 1.0);
}

如果你然後運行程式,你可能會注意到所有東西都是白色的,這使得看起來我們所有的深度值都是最大深度值 1.0。那麼為什麼沒有任何深度值更接近 0.0 因此更暗呢?

在上一節中,我們描述了螢幕空間中的深度值是非線性的,例如,對於小的 z 值,它們具有非常高的精度,而對於大的 z 值,則精度較低。片段的深度值隨距離快速增加,因此幾乎所有頂點的值都接近 1.0。如果我們小心地非常靠近一個物體,你最終可能會看到顏色變暗,它們的 z 值變小:

這清楚地顯示了深度值的非線性。近距離物體對深度值的影響遠大於遠距離物體。僅移動幾英寸就能導致顏色從深色變為完全白色。

然而,我們可以將片段的非線性深度值轉換回其線性對應值。為此,我們基本上需要單獨逆轉深度值的投影過程。這意味著我們必須首先將深度值從 [0,1] 範圍重新轉換為 [-1,1] 範圍的正規化設備座標。然後,我們需要逆轉投影矩陣中執行的非線性方程式(方程式 2),並將此逆轉方程式應用於結果深度值。結果便是線性深度值。

首先,我們將深度值轉換為 NDC,這並不難:

float ndc = depth * 2.0 - 1.0;

然後我們取得到的 ndc 值,並應用反向轉換以檢索其線性深度值:

float linearDepth = (2.0 * near * far) / (far + near - ndc * (far - near));

這個方程式是從投影矩陣中推導出來的,用於深度值的非線性化,返回介於 nearfar 之間的深度值。這篇數學文章為有興趣的讀者詳細解釋了投影矩陣;它也展示了這些方程式的來源。

將螢幕空間中的非線性深度轉換為線性深度值的完整片段著色器如下:

#version 330 core
out vec4 FragColor;

float near = 0.1;
float far  = 100.0;

float LinearizeDepth(float depth)
{
    float z = depth * 2.0 - 1.0; // back to NDC
    return (2.0 * near * far) / (far + near - z * (far - near));
}

void main()
{
    float depth = LinearizeDepth(gl_FragCoord.z) / far; // divide by far for demonstration
    FragColor = vec4(vec3(depth), 1.0);
}

由於線性化後的深度值範圍從 nearfar,因此其大部分值將大於 1.0 並顯示為完全白色。透過在 main 函數中將線性深度值除以 far,我們將線性深度值轉換為 [0,1] 範圍。這樣一來,片段越接近投影錐體的遠平面,我們就能逐漸看到場景變得越亮,這更適合視覺化目的。

如果我們現在運行應用程式,我們將獲得隨距離線性變化的深度值。嘗試在場景中移動,以觀察深度值以線性方式變化。

色彩大部分呈現黑色,因為深度值從「近」平面(0.1)到「遠」平面(100)呈線性分佈,而「遠」平面離我們仍相當遙遠。結果是我們相對靠近「近」平面,因此獲得較低(較暗)的深度值。

Z-fighting

當兩個平面或三角形彼此過於緊密對齊時,深度緩衝區可能沒有足夠的精度來判斷兩個形狀中哪一個在前。結果是這兩個形狀似乎不斷地切換順序,導致奇怪的故障模式。這稱為「Z 軸戰鬥」(z-fighting),因為看起來這些形狀正在爭奪誰在上方。

在我們目前使用的場景中,有幾個地方可以注意到 Z 軸戰鬥。容器放置在與地板完全相同的高度,這意味著容器的底部平面與地板平面共面。這兩個平面的深度值相同,因此深度測試無法判斷哪一個是正確的。

如果你將攝影機移到其中一個容器內部,效果會清晰可見,容器的底部部分以鋸齒狀模式不斷地在容器平面和地板平面之間切換:

Z 軸戰鬥是深度緩衝區的常見問題,當物體距離較遠時(因為深度緩衝區在較大的 z 值處精度較低),通常會更為明顯。Z 軸戰鬥無法完全避免,但有一些技巧可以幫助緩解或完全防止場景中的 Z 軸戰鬥。

防止 z-fighting

第一個也是最重要的技巧是,絕不要將物體放置得太近,以至於它們的一些三角形密切重疊。透過在兩個物體之間建立一個小小的偏移,你可以完全消除兩者之間的 Z 軸戰鬥。以容器和平面為例,我們原本可以輕鬆地將容器稍微向上移動一小段距離(正 y 方向)。容器位置的微小改變可能根本不會被察覺,並且會徹底減少 Z 軸戰鬥。然而,這需要對每個物體進行手動干預和徹底測試,以確保場景中沒有物體產生 Z 軸戰鬥。

第二個技巧是將近平面設定得盡可能遠。在前面的章節中,我們討論過靠近「近」平面時精度極高,因此如果我們將「近」平面從觀察者移開,我們將在整個視錐體範圍內獲得顯著更高的精度。然而,將「近」平面設定得太遠可能會導致近處物體被裁剪,因此通常需要透過調整和實驗來找出場景最佳的「近」距離。

另一個以犧牲部分性能為代價的絕佳技巧是使用更高精度的深度緩衝區。大多數深度緩衝區的精度為 24 位元,但現在大多數 GPU 都支援 32 位元深度緩衝區,顯著增加了精度。因此,以犧牲部分性能為代價,你將在深度測試中獲得更高的精度,從而減少 Z 軸戰鬥。

我們討論的這三種技術是最常見且易於實施的反 Z 軸戰鬥技術。還有一些其他技術需要更多的工作,但仍然無法完全禁用 Z 軸戰鬥。Z 軸戰鬥是一個常見問題,但如果你使用列出技術的適當組合,你可能不需要太多處理 Z 軸戰鬥。