图形学篇 — 深度法线纹理

Posted by Xun on Monday, January 26, 2026

深度和法线纹理为游戏开发过程中常用的信息,通常情况下存储在深度法线纹理中,使用时需要进行对应的转换。

简介

  • 在游戏开发过程中,深度和法线信息起着重要的作用。在渲染管线中,深度和法线通常会存储在深度法线纹理中,供各个阶段使用。其中,纹理的生成规则和使用方式相互关联,为了能够了解其使用方法,需要认识其生成过程。

深度纹理

深度值

正交投影

  • 经过投影变换和齐次除法后,可以得到裁剪空间下的深度 z_NDC Math_DepthNormalTexture_1.png 其中 z_view 为观察空间下的深度。
  • 在像素着色器中,每一个像素的坐标都需要通过插值计算得到。然而,计算结果的值存储在 z-buffer 中,渲染管线通常不能直接进行操作,因此需要存入到深度纹理中。由于深度纹理的范围为 [0, 1] ,因此深度纹理中的 z_depth 值为 Math_DepthNormalTexture_2.png
  • 由于 OpenGL 中观察空间为右手坐标系,而裁剪空间下是左手坐标系,所以观察空间下的 z_view 值需要取反,才为左手坐标系下的线性 z_eye 值,即 Math_DepthNormalTexture_3.png
  • com.unity.render-pipelines.core/ShaderLibrary/ShaderVariablesFunctions.hlsl 中,获取线性深度的方法为
// ShaderVariablesFunctions.hlsl
...

float LinearDepthToEyeDepth(float rawDepth)
{
    #if UNITY_REVERSED_Z
        return _ProjectionParams.z - (_ProjectionParams.z - _ProjectionParams.y) * rawDepth;
    #else
        return _ProjectionParams.y + (_ProjectionParams.z - _ProjectionParams.y) * rawDepth;
    #endif
}

...
  • 其中,用于在 shader 中计算深度的 _ProjectionParams 参数值为 Math_DepthNormalTexture_4.png

透视投影

  • 透视投影下的裁剪空间深度 z_NDC Math_DepthNormalTexture_5.png
  • 在像素着色器中,每一个像素的坐标都需要通过插值计算得到。而在透视投影下,需要使用透视校正插值。如果深度纹理使用观察空间下的 z_view ,则在插值过程顶点坐标需要除以 z_view ,因此深度纹理使用 z_NDC 可以减少计算复杂度。
  • 齐次除法后的 z_NDC 值已经不为观察空间下 z_view 值的线性变换,因此使用深度纹理的深度信息时,需要变换观察空间下才能进行其他计算,即 Math_DepthNormalTexture_6.pngz_eyeMath_DepthNormalTexture_7.png
  • 当相机位置为 0 ,远裁剪平面为 1 时,即在左手坐标系下整体缩小 f ,则深度值 z_linear01Math_DepthNormalTexture_8.png
  • 当近裁剪平面为 0 ,原裁剪平面为 1 时,即在左手坐标系下先移动 -n ,再整体缩小 f ,则深度值 z_linear01FromNearMath_DepthNormalTexture_9.png
  • com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl 中,获取线性深度的方法为
// Common.hlsl
...

// Z buffer to linear 0..1 depth (0 at near plane, 1 at far plane).
// Does NOT correctly handle oblique view frustums.
// Does NOT work with orthographic projection.
// zBufferParam (UNITY_REVERSED_Z) = { f/n - 1,   1, (1/n - 1/f), 1/f }
// zBufferParam                    = { 1 - f/n, f/n, (1/f - 1/n), 1/n }
float Linear01DepthFromNear(float depth, float4 zBufferParam)
{
    #if UNITY_REVERSED_Z
    return (1.0 - depth) / (zBufferParam.x * depth + zBufferParam.y);
    #else
    return depth / (zBufferParam.x * depth + zBufferParam.y);
    #endif
}

// Z buffer to linear 0..1 depth (0 at camera position, 1 at far plane).
// Does NOT work with orthographic projections.
// Does NOT correctly handle oblique view frustums.
// zBufferParam (UNITY_REVERSED_Z) = { f/n - 1,   1, (1/n - 1/f), 1/f }
// zBufferParam                    = { 1 - f/n, f/n, (1/f - 1/n), 1/n }
float Linear01Depth(float depth, float4 zBufferParam)
{
    return 1.0 / (zBufferParam.x * depth + zBufferParam.y);
}

// Z buffer to linear view space (eye) depth.
// Does NOT correctly handle oblique view frustums.
// Does NOT work with orthographic projection.
// zBufferParam (UNITY_REVERSED_Z) = { f/n - 1,   1, (1/n - 1/f), 1/f }
// zBufferParam                    = { 1 - f/n, f/n, (1/f - 1/n), 1/n }
float LinearEyeDepth(float depth, float4 zBufferParam)
{
    return 1.0 / (zBufferParam.z * depth + zBufferParam.w);
}

...
  • 其中,用于在 shader 中计算深度的 _ZBufferParams 参数值为 Math_DepthNormalTexture_10.png

Reversed-Z 优化

  • 正交投影下,z_NDCz_view 是线性关系,而透视投影下,由于 z_NDC 是按 1/z_view 关系分布的,因此,z_NDC 区间的绝大部分值,都映射到接近近裁剪平面附近,精度较高,而靠近远裁剪平面部分,z_NDC 的变化很小,精度较低,即在较大区间范围内的所有 z_view 都对应同一个 z_NDC 值,因此容易出现 z-fighting 问题。而在近裁剪平面附近,通常也不需要这么高的精度。
    DepthNormalTexture_1.png
    引自 Depth Precision Visualized
  • 在 D3D 中,z_NDC 的范围为 [0, 1] ,对应 z_view[near, far] ,而深度值通常使用 4 bit 来存储浮点数。根据浮点数的量化规则,当浮点数越接近 0 时,同样位数的存储能有更大的精度,即能表示更小的浮点数,但这些值对应的 z_view 变化范围非常小。
    DepthNormalTexture_2.png
    引自 Depth Precision Visualized
  • 如果将深度值按 1 - z_NDC 存储,即远裁剪平面对应 z_NDC 为 0,利用浮点数的特点,可以在靠近远裁剪平面获得更大的精度,一定程度上抵消非线性深度的问题,这种方式称为 Reversed-Z
    DepthNormalTexture_3.png
    引自 Depth Precision Visualized
  • 然而,在 OpenGL 中,由于 z_NDC 的范围为 [-1, 1] ,浮点数的高精度部分集中在中间区域,远裁剪平面无法通过使用 Reversed-Z 优化。尽管 z_NDC 在后续会将值映射到 [0, 1] 存入深度纹理,但由于初始映射到 [-1, 1] 的过程已经破坏了远裁剪平面部分的精度,所以 Reversed-Z 作用不大。
    DepthNormalTexture_4.png
    引自 Depth Precision Visualized

计算观察坐标

  • 根据近裁剪平面上的顶点坐标信息,加上深度纹理的 uv 坐标和深度信息,可以计算出目标点在观察空间下的坐标。
  • 对于正交投影,目标点观察空间下的 xy 坐标,可由近裁剪平面的左上顶点的 xy 坐标,加上 uv 偏移得到,z 坐标则可由近裁剪平面的 z 坐标,加上当前和近裁剪平面的距离得到,即 Math_DepthNormalTexture_11.png
  • 对于透视投影,根据变换的关系,可以得到观察空间下目标点的坐标和其投影到近裁剪平面上的点的坐标关系 Math_DepthNormalTexture_12.png 同样使用近裁剪平面的左上顶点和 uv 偏移,则有 Math_DepthNormalTexture_13.png

法线纹理

  • 在 Built-In 管线中,法线没有单独的纹理,如果需要法线,则会将法线和深度一起存储到深度法线纹理中,存储时通过 EncodeDepthNormal 方法,将深度编码到 RG 通道,将法线编码到 BA 通道,读取时再通过 DecodeDepthNormal 进行解码,得到深度和法线。其中,法线的编码方法 EncodeViewNormalStereo
// UnityCG.cginc

...

inline float2 EncodeViewNormalStereo( float3 n )
{
    float kScale = 1.7777;
    float2 enc;
    enc = n.xy / (n.z+1);
    enc /= kScale;
    enc = enc*0.5+0.5;
    return enc;
}
  • 单位法线向量的点落在单位球面上,以 (0, 0, -1) 为投影中心,z = 0 为投影平面,将所有点投影到投影平面上,则有 Math_DepthNormalTexture_14.png x_0y_0 为投影平面上点的坐标,z_o 为投影中心点的 z 坐标,即 Math_DepthNormalTexture_15.png
  • 在 URP 中,法线则是通过 DepthNormalOnlyPass ,渲染所有对象的 DepthNormalsDepthNormalsOnly 标签的 pass,结果写入到深度图_CameraNormalsTexture 中。

总结

  • 深度和法线纹理,随着版本的变更,也出现了一些新的调整,但其原理基本上是相同的。了解深度法线纹理的生成过程,对于复杂效果的实现有比较大的帮助。

参考