bcjohn's blog
Three.js 中物體的遠近關係 (4) - 對數深度值
發布於: 2025-03-17 更新於: 2025-04-29 分類於: Three.js 閱讀次數: 
此為 Three.js 中物體的遠近關係 系列文章 - 第 4 篇:
  1. Three.js 中物體的遠近關係 (1) - 什麼是深度測試?
  2. Three.js 中物體的遠近關係 (2) - 左手/右手座標系與齊次座標
  3. Three.js 中物體的遠近關係 (3) - 深度值的計算方式
  4. Three.js 中物體的遠近關係 (4) - 對數深度值
  5. Three.js 中物體的遠近關係 (5) - 渲染物體的順序

前言

上一篇中我們知道透視投影的深度值 $ z_{depth} $ 和 $ 1/z $ 成正比,如此與人眼感知的狀況相符,對於近處物體的分辨率較高,而遠處物體不容易分辨清楚互相的前後關係,大部分情況下這個深度值的轉換函式可以很好的描述物體的遠近,但如果套用到大尺度的場景,例如:太陽系、宇宙等,這種深度值的轉換函式就會出問題,這篇文章討論會出現哪些問題,以及最後如何使用對數深度值轉換函式解決

深度值轉換函式的問題點

上一篇文章中推導出深度值轉換的函式如下:

$$ \large z_{depth} = \frac{1/z - 1/near}{1/far - 1/near} $$

Z-value 的範圍在 [1, 50] 來看,大約有九成的深度值都位在 Z-value 小於 10 的範圍內,這意味著離相機越遠的物體,深度值可以覆蓋的範圍越少,也就是說越遠的物體越難分辨他們的前後關係,對人眼來說這是正常的機制,但如果打算在 Three.js 中渲染大尺度的場景,可以想像位於非常遠處的兩個物體深度值會非常接近於 1,例如:0.9999991 v.s. 0.9999992,再加上電腦能儲存浮點數後的小數位數是有限制的,當四捨五入後兩個物體的深度值很有可能都等於 1,無法判別這兩個物體究竟誰在前誰在後,此時會看到畫面兩個物體互相重疊出現閃爍,這就是所謂的 z-fighting 問題

大尺度場景下導致的 z-fighting

Three.js 的其中一個 官方範例 很好的展示了這個問題,畫面的左邊套用的是正常深度值轉換函式,也就是 $ z_{depth} $ 與 $ 1/z $ 成正比,而右邊的是 Three.js 內建的對數深度值轉換函式,一進去這個場景會從 $ 10^{-6} \; \small m $ 距離開始逐步將相機拉遠到 $ 10^{19} \; \small m $ 遠的地方,從以下截圖可以看到當相機拉遠到太陽的尺度 $ 1.4 \times 10^{5} \; \small km $ 時,左邊的畫面不斷閃爍出現很明顯的 z-fighting

對數深度值

Three.js 中啟用對數深度值的方式非常簡單,只要在 WebGLRenderer 中將 logarithmicDepthBuffer 設為 true 就好了,以上範例左、右畫面在實作上就只是 logarithmicDepthBuffer 有沒有開啟而已

1
const renderer = new THREE.WebGLRenderer({ logarithmicDepthBuffer: true });

光調整一個參數就可以讓畫面的呈現有這麽大的差別真是神奇,以下讓我們來看看 logarithmicDepthBuffer 底層的邏輯是如何實作的

P.S. 在這之前建議先閱讀 理解-three-js-的-shader-架構,了解 Three.js 中的 shader 是如何作用的

定義 USE_LOGDEPTHBUF 常數

首先在 WebGLProgram.js 檔案裡,當 logarithmicDepthBuffer = true 時,會在 shaders 中定義 USE_LOGDEPTHBUF 常數

1
parameters.logarithmicDepthBuffer ? '#define USE_LOGDEPTHBUF' : '',

USE_LOGDEPTHBUF 被定義時,會在 vertex shader 及 fragment shader 中添加一些變數,這些變數最後用來計算對數深度值

Vertex shader

1
2
3
4
5
6
7
8
export default /* glsl */ `
#ifdef USE_LOGDEPTHBUF

varying float vFragDepth;
varying float vIsPerspective;

#endif
`;
1
2
3
4
5
6
7
8
export default /* glsl */ `
#ifdef USE_LOGDEPTHBUF

vFragDepth = 1.0 + gl_Position.w;
vIsPerspective = float( isPerspectiveMatrix( projectionMatrix ) );

#endif
`;

Fragment shader

1
2
3
4
5
6
7
8
9
export default /* glsl */ `
#if defined( USE_LOGDEPTHBUF )

uniform float logDepthBufFC;
varying float vFragDepth;
varying float vIsPerspective;

#endif
`;
1
2
3
4
5
6
7
8
9
export default /* glsl */ `
#if defined( USE_LOGDEPTHBUF )

// Doing a strict comparison with == 1.0 can cause noise artifacts
// on some platforms. See issue #17623.
gl_FragDepth = vIsPerspective == 0.0 ? gl_FragCoord.z : log2( vFragDepth ) * logDepthBufFC * 0.5;

#endif
`;

對數深度值公式

其中最重要的變數是 gl_FragDepthgl_FragDepth 代表這個 fragment 的深度值,在這個式子中先利用 vIsPerspective 判斷是否為透視投影,當 vIsPerspective == 0 時不是透視投影,深度值套用預設的 gl_FragCoord.z,那如果是透視投影的話套用 log2( vFragDepth ) * logDepthBufFC * 0.5

1
2
3
4
gl_FragDepth =
vIsPerspective == 0.0
? gl_FragCoord.z
: log2(vFragDepth) * logDepthBufFC * 0.5;

其中 vFragDepthlogDepthBufFC 的數值是從其他檔案引入進來的:

1
vFragDepth = 1.0 + gl_Position.w;
1
2
3
4
5
p_uniforms.setValue(
_gl,
'logDepthBufFC',
2.0 / (Math.log(camera.far + 1.0) / Math.LN2)
);

綜合以上資訊,透視投影的對數深度值公式以數學形式表示如下:

$$ z_{depth} = \log_2(1 + w) \times \frac{2}{\frac{\log_e(f + 1)}{\log_e2}} \times 0.5 $$

套用以下已知條件,可以進一步將公式改寫:

1. $ w $ 替換成 $ -z $

在上一篇文章 - 透視除法 有求出 $ w = -z $,$ w $ 位於 裁剪座標(clip coordinates) 下,而 $ z $ 位於 觀察座標(view coordinates)

上面計算 vFragDepth 中的 gl_Position.w 指的是 螢幕座標 (screen coordinate) 下的 $ w $,由於裁剪座標的 $ w $ 在經過 視口變換(viewport transform)螢幕座標$ w $ 維持不變,因此計算 vFragDepth 中的 $ 1 + w $ 可以變成 $ 1 - z $,其中的 $ z $ 是觀察座標下的 $ z $

2. 改寫對數部分

套用 換底公式、倒數公式

$$ \begin{align*} \displaystyle \frac{2}{\frac{\log_e(f + 1)}{\log_e2}} \times 0.5 &= \frac{\log_e2}{\log_e(f + 1)} = \frac{1}{\log_2(f + 1)} \\ \end{align*} $$

所以改寫後的深度值如下:

$$ z_{depth} = \frac{\log_2(-z + 1)}{\log_2(f + 1)} $$

為什麼對數深度值的公式長這樣?

剛看到這個公式其實我想了很久,不知道為什麼對數深度值的公式是這個形式,後來研究許久後得出一些結論:

1. 為什麼分子是 $ -z + 1 $ ?

一開始可以想到最簡單取對數的方式就是 $ \log_2{z} $,我們知道 $ z $ 的範圍會從 近平面(-n)遠平面(-f),其中近平面的距離可以小到完全接近相機也就是 $ z = 0 $,此時深度值的函式會變成 $ z_{depth} = \log_2{0} = -\infty $,由於深度值的範圍定義在 [0, 1] 之間,所以很明顯這樣是不合理的,而當分子改成 $ -z + 1 $ 時, $ z_{depth} = \log_2(-0 + 1) = \log_21 = 0 $,如此我們可以將 $ z $ 的最小值 $ 0 $ 正確映射到深度值區間的最小值 $ 0 $

2. 為什麼分母是 $ f + 1 $ ?

當 $ z $ 值拉到最遠等於 遠平面(-f) 時,分子會變成 $ z_{depth} = \log_2(f + 1) $,為了要符合深度值最大為 $ 1 $ 的限制,因此就必須除以分母 $ \log_2(f + 1) $

正常深度值與對數深度值的比較

接著我們以一開始提到 Three.js 大尺度場景的範例來看深度值套用兩種不同公式間的差別,設定物體與相機的距離 $ z $ 範圍為 $ 10^{-6} $ 到 $ 10^{19} $,畫出正常深度值(左圖) 與 對數深度值(右圖)

正常深度值
對數深度值

可以看到之前正常深度值的優點是符合人眼感知的狀況,在近的地方深度值覆蓋範圍大,適合分辨近處物體,但當場景需要容納大尺度的 $ z $ 值時,這就變成缺點了,可以看到從 $ 10^3 $ 左右一直到 $ 10^{18} $ 的距離,轉換成深度值後幾乎都等於 $ 1 $,這導致 shader 在判定物體的遠近時,兩個物體深度值的差異小到無法正確判別,就導致畫面上閃爍的 z-fighting 問題了。反過來看對數深度值則是在 $ 10^0 $ 到 $ 10^{18} $ 之間分佈平均,可以正常判別物體深度值之間的差異

深度值的精度

最終我們來討論深度值的精度是如何設置的,上面提到藉由比較兩個物體的深度值判斷誰應該在前誰應該在後,而在電腦中是以浮點數的方式儲存深度值的,當兩個浮點數的差值小到電腦無法分辨的時候就會引起 z-fighting 的問題。上面我們在 fragment shader 中使用 gl_FragDepth 賦予每個 fragment 的深度值,在 Shader 數值的精度設置 的部分有提過,fragment shader 中可設置的精度有三種 - highp, mediump, lowp

精細度 說明 適用場景
highp 高精度 (32 位浮點數) 需要高精度的計算,例如:光照、物理模擬等
mediump 中精度 (16 位浮點數) 對精度要求中等的場景,例如:紋理 UV 坐標、顏色
lowp 低精度 (8 位浮點數) 不太需要精度的場景,例如:簡單的顏色計算

在 Three.js 中創建 WebGLRenderer 時預設將 精度(precision) 設為 highp,或是在 Material.precision 中也可以個別修改 fragment 的精度

接著我們以大尺度場景的範例來看,當 $ near = 10^{-6}, far = 10^{19} $ 時,兩個很遠的物體 $ z_1 = 10^{15}, z_2 = 10^{18} $ 時他們的深度值差異是多少:

  • 正常深度值的差異
$$ z_{depth1} = \frac{1/z_1 - 1/near}{1/far - 1/near} = \frac{10^{-15} - 10^6}{10^{-19} - 10^6} \approx \frac{-10^6}{-10^6} = 1 $$ $$ z_{depth2} = \frac{1/z_2 - 1/near}{1/far - 1/near} = \frac{10^{-18} - 10^6}{10^{-19} - 10^6} \approx \frac{-10^6}{-10^6} = 1 $$
  • 對數深度值的差異
$$ z_{depth1} = \frac{\log_2(z_1 + 1)}{\log_2(far + 1)} = \frac{\log_2(10^{15} + 1)}{\log_2(10^{19} + 1)} \approx 0.7895 $$ $$ z_{depth2} = \frac{\log_2(z_2 + 1)}{\log_2(far + 1)} = \frac{\log_2(10^{18} + 1)}{\log_2(10^{19} + 1)} \approx 0.9474 $$

由於電腦只能用有限的位數儲存浮點數,所以當數值相差過大的數值進行加減時,有些位數就會直接被忽略 (ex. $ 10^{-15} - 10^6 \approx -10^6 $),所以正常深度值的公式至少在 $ z > 10^{15} $ 以上的區間就已經無法分辨物體的遠近關係了,而對數深度值公式即使是 $ z $ 很大的狀況下一樣可以求出有效的深度值

延伸閱讀

1. gl_FragDepth 的相容性

gl_FragDepth 是 WebGL 2.0 中改變 fragment 深度值的方式,在 WebGL 1.0 中則是使用 gl_FragDepthEXT,在 Three.js 的原始碼中對這部分進行了相容性的處理如果是 WebGL 1.0 的 gl_FragDepthEXT 的話會重新命名成 gl_FragDepth,因此最後賦予深度值的變數只需要使用 gl_FragDepth 就好

WebGLProgram.js

1
2
3
4
5
6
7
prefixFragment = [
'#define varying in',
( parameters.glslVersion === GLSL3 ) ? '' : 'layout(location = 0) out highp vec4 pc_fragColor;',
( parameters.glslVersion === GLSL3 ) ? '' : '#define gl_FragColor pc_fragColor',
'#define gl_FragDepthEXT gl_FragDepth',
...
]

2. isPerspectiveMatrix 的實作

shader 中的 isPerspectiveMatrix 函式定義在 common.glsl.js 檔案中,是用投影矩陣的第三行第四列是否等於 -1 判斷透視投影矩陣

透視投影矩陣 $$ \begin{bmatrix} \displaystyle \frac{2n}{r-l} & 0 & \displaystyle \frac{r+l}{r-l} & 0 \\ 0 & \displaystyle \frac{2n}{t-b} & \displaystyle \frac{t+b}{t-b} & 0 \\ 0 & 0 & \displaystyle -\frac{f+n}{f-n} & \displaystyle -\frac{2 f n}{f - n} \\ 0 & 0 & -1 & 0 \\ \end{bmatrix} $$
正交投影矩陣 $$ \begin{bmatrix} \displaystyle \frac{1}{r} & 0 & 0 & 0 \\ 0 & \displaystyle \frac{1}{t} & 0 & 0 \\ 0 & 0 & \displaystyle -\frac{2}{f-n} & \displaystyle -\frac{f+n}{f-n} \\ 0 & 0 & 0 & 1 \\ \end{bmatrix} $$

透視投影矩陣與正交投影矩陣的推導過程請見 Projection Matrix

common.glsl.js

1
2
3
bool isPerspectiveMatrix( mat4 m ) {
return m[ 2 ][ 3 ] == - 1.0;
}

3. gl_FragCoord.z 的定義

在 fragment shader 中可以取得的 gl_FragCoord 是 OpenGL 中定義的變數,其中 gl_FragCoord.z 指的就是 [0, 1] 區間的深度值,因此當不是透視投影時(vIsPerspective == 0.0),將預設的深度值 gl_FragCoord.z 賦值給 gl_FragDepth

1
gl_FragDepth = vIsPerspective == 0.0 ? gl_FragCoord.z : log2( vFragDepth ) * logDepthBufFC * 0.5;

4. issue #17623 的浮點數精度 bug

在計算 gl_FragDepth 的地方有一段註解說明 issue #17623 的 bug:

1
2
3
4
5
6
7
8
9
export default /* glsl */ `
#if defined( USE_LOGDEPTHBUF )

// Doing a strict comparison with == 1.0 can cause noise artifacts
// on some platforms. See issue #17623.
gl_FragDepth = vIsPerspective == 0.0 ? gl_FragCoord.z : log2( vFragDepth ) * logDepthBufFC * 0.5;

#endif
`;

進一步查詢可以發現之前的程式是這樣寫的:

1
2
3
4
5
6
7
export default /* glsl */ `
#if defined( USE_LOGDEPTHBUF )

gl_FragDepth = vIsPerspective == 1.0 ? log2( vFragDepth ) * logDepthBufFC * 0.5 : gl_FragCoord.z;

#endif
`;

可以看到計算深度值的邏輯完全沒變,差別只在以前是 vIsPerspective == 1.0 而現在是判斷 vIsPerspective == 0.0,經過一些研究後看起來是判斷 vIsPerspective == 1.0 時會有浮點數精度誤差的問題,也就是說有些 GPU 的實作 1.0 可能會變成 0.999999941.0000001 導致判斷失效,因此最後解決方案改為判斷 vIsPerspective == 0.0

參考資料

Logarithmic depth buffer optimizations & fixes
Cesium 和 three.js 对数深度缓冲原理简析
gl_FragCoord.z 的定義討論
gl_FragCoord 官方文檔
gl_FragCoord 的含义
How Does a camera convert from clip space into screen space?
OpenGL clip space, NDC, and screen space