着色器中非线性深度转换成线性深度

在 OpenGL 中,深度缓冲(Depth Buffer)中存储的值(通常记为 $z_{buffer}$ 或 $d$)是非线性的。这意味着深度值 $0.5$ 并不代表物体位于 nearfar 平面的正中间。

为了在 Shader 中进行后期处理(如 SSAO、景深效果、体积光),我们通常需要将这个非线性的深度值还原为观察空间(View Space)下的线性深度(Linear Depth, 即物体距离相机的真实距离)。

以下是完整的数学推导过程。


1. 坐标变换流水线

要推导逆变换,必须先理解从观察空间深度缓冲的正向过程:

  1. 观察空间 (View Space): 点 $P_{view} = (x_e, y_e, z_e, 1)^T$。

    • 在 OpenGL 默认右手坐标系中,相机位于原点看向 $-z$ 轴,所以可见物体的 $z_e$ 是负值(例如 $-5.0$)。
    • 我们需要求的线性深度 $z_{linear}$ 通常指定义为正数距离:$z_{linear} = -z_e$。
  2. 裁剪空间 (Clip Space): 应用透视投影矩阵后。

  3. 归一化设备坐标 (NDC): 进行透视除法(除以 $w$)。

  4. 屏幕空间 (Window Space): 深度值映射到 $[0, 1]$ 区间。

2. 关键矩阵与公式

第一步:透视投影 (Projection)

标准的 OpenGL 透视投影矩阵(gluPerspectiveglm::perspective)的第三行和第四行决定了 $z$ 和 $w$ 的变换。

矩阵形式简化如下(设 $n$ 为近平面,$f$ 为远平面):
$$
M = \begin{pmatrix}
\dots & 0 & 0 & 0 \
\dots & 0 & 0 & 0 \
0 & 0 & A & B \
0 & 0 & -1 & 0
\end{pmatrix}
$$
其中系数为:
$$ A = -\frac{f+n}{f-n}, \quad B = -\frac{2fn}{f-n} $$

裁剪空间坐标 $(z_{clip}, w_{clip})$ 计算如下:
$$ z_{clip} = A \cdot z_e + B \cdot 1 = -\frac{f+n}{f-n} z_e - \frac{2fn}{f-n} $$
$$ w_{clip} = -1 \cdot z_e = -z_e $$

第二步:透视除法 (To NDC)

将裁剪坐标除以 $w_{clip}$ 得到 NDC 坐标 $z_{ndc}$。OpenGL 的 $z_{ndc}$ 范围是 $[-1, 1]$。

$$ z_{ndc} = \frac{z_{clip}}{w_{clip}} = \frac{-\frac{f+n}{f-n} z_e - \frac{2fn}{f-n}}{-z_e} $$

化简该公式:
$$ z_{ndc} = \frac{f+n}{f-n} + \frac{2fn}{z_e(f-n)} $$
(注意:这里清楚地表明了 $z_{ndc}$ 与 $1/z_e$ 成正比,这就是非线性的来源)

第三步:深度缓冲映射 (To Depth Buffer)

硬件会自动将 $[-1, 1]$ 的 $z_{ndc}$ 映射到 $[0, 1]$ 的深度缓冲值 $z_{b}$ (即我们在 Shader 中读到的纹理值)。

$$ z_b = 0.5 \cdot z_{ndc} + 0.5 $$
反之:
$$ z_{ndc} = 2 \cdot z_b - 1 $$


3. 逆向推导 (从深度值求线性距离)

现在我们将上述过程反转,已知 $z_b$,求解 $z_e$。

步骤 A: 从 $z_b$ 恢复 $z_{ndc}$

$$ z_{ndc} = 2 \cdot z_b - 1 $$

步骤 B: 代入 NDC 公式解 $z_e$

利用第二步推导的公式:
$$ z_{ndc} = \frac{f+n}{f-n} + \frac{2fn}{z_e(f-n)} $$

将 $z_{ndc}$ 移项,把含 $z_e$ 的项分离出来:
$$ z_{ndc} - \frac{f+n}{f-n} = \frac{2fn}{z_e(f-n)} $$

通分左边:
$$ \frac{z_{ndc}(f-n) - (f+n)}{f-n} = \frac{2fn}{z_e(f-n)} $$

两边同时乘以 $(f-n)$ 消去分母:
$$ z_{ndc}(f-n) - (f+n) = \frac{2fn}{z_e} $$

解出 $z_e$:
$$ z_e = \frac{2fn}{z_{ndc}(f-n) - (f+n)} $$

步骤 C: 转换为线性距离 (正值)

因为 OpenGL 视图空间中相机前方的 $z_e$ 是负数,我们通常需要正的距离 $z_{linear}$:
$$ z_{linear} = -z_e = \frac{-2fn}{z_{ndc}(f-n) - (f+n)} $$

将分母符号取反,分子变得整洁:
$$ z_{linear} = \frac{2fn}{(f+n) - z_{ndc}(f-n)} $$

(这是常用的中间公式,使用 $z_{ndc}$)


步骤 D: 代入 $z_b$ 得到最终公式

将 $z_{ndc} = 2z_b - 1$ 代入上式:

分母部分:
$$ \text{Denom} = (f+n) - (2z_b - 1)(f-n) $$
$$ = f + n - [2z_b(f-n) - (f-n)] $$
$$ = f + n - 2z_b(f-n) + f - n $$
$$ = 2f - 2z_b(f-n) $$

代回原式:
$$ z_{linear} = \frac{2fn}{2f - 2z_b(f-n)} $$

分子分母同时除以 2:
$$ z_{linear} = \frac{fn}{f - z_b(f-n)} $$

或者写成更常见的加权形式:
$$ z_{linear} = \frac{f \cdot n}{f - z_b \cdot f + z_b \cdot n} = \frac{n \cdot f}{z_b(n - f) + f} $$


4. 最终结论与代码

数学公式

$$ z_{linear} = \frac{n \cdot f}{f - z_{buffer} \cdot (f - n)} $$

或者:
$$ z_{linear} = \frac{1}{\frac{z_{buffer}}{n} + \frac{1-z_{buffer}}{f}} $$

GLSL 实现

在 Fragment Shader 中,假设你已经采样了深度纹理得到 depth(即 $z_b$,范围 0~1):

1
2
3
4
5
6
7
8
9
float LinearizeDepth(float depth)
{
float z_ndc = depth * 2.0 - 1.0; // Back to NDC [-1, 1]
float near = 0.1; // 投影矩阵的 near plane
float far = 100.0; // 投影矩阵的 far plane

// 使用推导出的中间公式 (Steps C)
return (2.0 * near * far) / (far + near - z_ndc * (far - near));
}

或者使用化简后的最终公式(计算量稍小):

1
2
3
4
5
6
7
float LinearizeDepthOptimized(float depth)
{
float n = 0.1;
float f = 100.0;
// 对应公式: (n * f) / (f - depth * (f - n))
return (n * f) / (f - depth * (f - n));
}

总结

  1. 非线性来源:透视除法 ($1/z$)。
  2. 推导核心:利用投影矩阵公式,反解 $z_e$。
  3. 结果:深度主要聚集在近平面附近,远处的精度会迅速下降。还原线性深度本质上是把这个 $1/z$ 的分布拉直。