【转载+修正+补充】Unity 从深度缓冲重建世界空间位置

778 阅读7分钟

原文链接

Unity从深度缓冲重建世界空间位置 | CJT

原文写得很好,但是主题格式我不太喜欢,所以我对文章的格式和部分错误的地方进行了补充校正,方便自己日后阅读

正文

在某些特定应用场景,比如说 屏幕空间反射,会要求我们从深度缓冲中重建像素点的世界空间位置。本文介绍在 Unity 中如何从深度缓冲中重建世界空间位置。

深度缓冲

首先先来看看在 Unity 中怎么计算深度。

  • UnityCG.cginc
#define COMPUTE_EYEDEPTH(o) o = -mul( UNITY_MATRIX_MV, v.vertex ).z
#define COMPUTE_DEPTH_01 -(mul( UNITY_MATRIX_MV, v.vertex ).z * _ProjectionParams.w)

其中,_ProjectionParams.w1 / FarPlane 1

符号取反 的原因是在 Unity 的 观察空间(View space)中 z 轴翻转了,摄像机的前向量就是 z 轴的正方向 2。这是和 OpenGL 中不一样的一点。

从上式可知,Unity 中的

  • 观察线性深度Eye depth)就是顶点在观察空间(View space)中的 z 分量,
  • 01 线性深度01 depth)就是观察线性深度通过除以 摄像机远平面 重新映射到 [0,1] 区间所得到的值。

我们可以从深度缓冲中采样得到深度值,并使用 Unity 中内置的功能函数将原始数据转换成 线性深度

  • UnityCG.cginc【具体详解见这篇文章
// Z buffer to linear 0..1 depth (0 at eye, 1 at far plane)
inline float Linear01Depth( float z )
{
    return 1.0 / (_ZBufferParams.x * z + _ZBufferParams.y);
}
// Z buffer to linear depth
inline float LinearEyeDepth( float z )
{
    return 1.0 / (_ZBufferParams.z * z + _ZBufferParams.w);
}

我的知识串联 —— 实际应用

  1. 护盾效果 image.png
  2. 水池边缘相交效果 image.png

一、从 NDC 空间中重建

第一种方法 是通过齐次坐标系下的屏幕坐标位置来计算。

首先将齐次坐标系下的屏幕坐标(未做透视除法)转换到 NDC 空间中。

float4 ndcPos = (o.screenPos / o.screenPos.w) * 2 - 1;

然后将(NDC 空间下的)屏幕像素对应在摄像机 远平面(Far plane)的点转换到 裁剪空间(Clip space)。因为在 NDC 空间 中远平面上的点的 z 分量为 1,所以可以直接乘以摄像机的 far 值来将其转换到 裁剪空间

我的补充:实际就是 视锥体深度范围 的逆变换 。

float far = _ProjectionParams.z;
float3 clipVec = float3(ndcPos.x, ndcPos.y, 1.0) * far;

接着通过 逆投影矩阵(Inverse Projection Matrix)将点转换到 观察空间(View space)。

float3 o.viewVec = mul(unity_CameraInvProjection, clipVec.xyzz).xyz;

已知在 观察空间 中摄像机的位置 一定(0, 0, 0),所以从摄像机指向远平面上的点的 viewVec 就是其在 观察空间 中的方向。

viewVec 乘以 线性深度值,得到在深度缓冲中储存的值的 观察空间 位置。

float depth = UNITY_SAMPLE_DEPTH(tex2Dproj(_CameraDepthTexture, i.screenPos));
float3 viewPos = i.viewVec * Linear01Depth(depth);

最后将 观察空间 中的位置变换到 世界空间 中。

float3 worldPos = mul(UNITY_MATRIX_I_V, float4(viewPos, 1.0)).xyz;

附上在 Shader Graph 中的实现。这里 Unity 有 bug 导致如果使用 Transformation Matrix 节点的 Inverse Projection 会报错,所以这里使用了一个 Custom Function 节点输出一个 4x4 矩阵 unity_CameraInvProjection。理论上效果是一样的。

  • Shader Graph(Custom Function 输出的是 unity_CameraInvProjectionimage.png

我的知识串联 —— 针对作者 CJT 说 Unity 有 bug

即使用 Transformation Matrix 节点的 Inverse Projection 会报错。

知识串联_1:

URP_BlitRenderFeature 作者大大的方法,思路和本文作者 CJT 有点类似,也是用了自定义节点(unity_CameraInvProjection),只不过他基于的是 物体空间,深度选择的是 Eye 模式 URP_BlitRenderFeature.jfif

知识串联_2:

但是我看国外某大佬制作 Decal 效果 —— 同样使用深度(Raw 模式)信息结合 Screen Position(center 模式 —— 即 NDC 空间 + 居中变换)重构 世界空间 的位置 image.png

他采用的是 Inverse View Projection 选项,是可以正常使用的。

image.png

  • ShaderGraph 如下: image.png

二、在世界空间中重建(有争议

第二种方法 是利用 WorldSpaceViewDir 得到 世界空间 中的从物体顶点指向摄像机的方向向量来计算。

首先构造在 世界空间(输入为物体空间的)顶点指向摄像机 的向量。

o.worldSpaceDir = WorldSpaceViewDir(v.vertex);

我的补充

// Computes world space view direction
inline float3 WorldSpaceViewDir( in float4 v )
{
    return _WorldSpaceCameraPos.xyz - mul(_Object2World, v).xyz;
}

我认为原文这里其实这里应该加一个 负号,将它转为 摄像机指向物体顶点 的方向向量

世界空间 向量转换到 观察空间,存储其 z 分量的值。

注意 向量和位置的空间转换是不同的,当 w 分量为 0 的时候 Unity 会将其视为向量,而当 w 分量为 1 的时候 Unity 将其视为位置。

o.viewSpaceZ = mul(UNITY_MATRIX_V, float4(o.worldSpaceDir, 0.0)).z;

接着,在深度缓冲中采样。

这里使用 tex2Dproj 而不是 tex2D 的原因是: screenPos 是用 ComputeScreenPos 来计算得到的,用 tex2Dproj 可以刚好帮我们做透视除法。

float eyeDepth = UNITY_SAMPLE_DEPTH(tex2Dproj(_CameraDepthTexture, i.screenPos));
eyeDepth = LinearEyeDepth(eyeDepth);

因为深度缓存采样出并经过 LinearEyeDepth 变换后的 观察线性深度 就是其在 观察空间 中的 z 分量(但是由于在 Unity 使用的视角空间中,摄像机正向对应的 z 值均为负值,所以 eyeDepth 应该取负),所以根据向量的 z 分量计算其缩放因子,将向量缩放到实际的长度。

i.worldSpaceDir *= -eyeDepth / i.viewSpaceZ;

最后以摄像机为 起点,缩放后的向量为指向向量,得到像素点在世界空间中位置。

float3 worldPos = _WorldSpaceCameraPos + i.worldSpaceDir;

附上在 Shader Graph 中的实现。

  • Shader Graph image.png

三、正交摄像机的情况(有争议

unity_OrthoParams 各个分量的含义

  • x=width, y=height, z 没有定义
  • w=1.0 (该摄像机是 正交 摄像机)
  • w=0.0 (该摄像机是 透视 摄像机)

如果摄像机不是透视而是 正交 的,做法上就有些不同。计算 观察空间(View space)中顶点的 xy 分量。

float4 ndcPos = (o.screenPos / o.screenPos.w) * 2 - 1;
o.viewVec = float3(unity_OrthoParams.xy * ndcPos.xy, 0);

如果是正交摄像机,则会对深度缓冲中采样出的 rawDepth 进行特别的处理,即以下代码中的 ortho/far 作为 depth

该方法来自:Unity 下无视 摄像机模式裁剪平面 都能得到正确线性深度的方法 3

// Sample the depth texture to get the linear depth
float rawDepth = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.screenPos));
float ortho = (far - near) * (1 - rawDepth) + near;
float depth = lerp(LinearEyeDepth(rawDepth), ortho, unity_OrthoParams.w) / far;

rawDepth=1 的时候 ortho=near, depth=near/far
rawDepth=0 的时候 ortho=far, depth=1

接着再根据上一步得到的深度值 depth 在作线性插值,就得到顶点在 观察空间 中的 z 分量(注意取负值)。

// Linear interpolate between near plane and far plane by depth value
float z = -lerp(near, far, depth);

最后将 观察空间 中的位置转换到 世界空间

float3 worldPos = mul(UNITY_MATRIX_I_V, float4(viewPos, 1)).xyz;
// Wrong depth
// float depth = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.screenPos));

// Correct Depth
float rawDepth = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.screenPos));
float ortho = (far - near) * (1 - rawDepth) + near;
float depth = lerp(LinearEyeDepth(rawDepth), ortho, unity_OrthoParams.w) / far;

附上在 Shader Graph 中的实现。其中 Custom Function 节点为:

float ortho = (_ProjectionParams.z - _ProjectionParams.y) * (1 - rawDepth) 
              + _ProjectionParams.y;
depth =  lerp(eyeDepth, ortho, unity_OrthoParams.w) / _ProjectionParams.z;
  • Shader Graph image.png

注意

该方法我没有验证,但是从原文的评论来看,还是有点争议的 image.png


最后(完整代码)

以上方法可以根据实际渲染的对象是在世界中物体(贴花)还是屏幕大小的四边形(后处理)来灵活使用。

附上keijiro 大神写的代码作为参考,这个项目通过后处理将 深度缓冲 转换成 世界空间 位置并可视化。

我的补充

大神的源码已经 删除 了第二种方法(世界空间)并 修改 了第三种(正交相机

1. NDC 方法

与大神仓库的源码略有不同

#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

struct v2f
{
    float4 vertex : SV_POSITION;
    float4 screenPos : TEXCOORD0;
    float3 viewVec : TEXCOORD1;
};

v2f vert(appdata_base v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);

    // Compute texture coordinate
    o.screenPos = ComputeScreenPos(o.vertex);

    // NDC position
    float4 ndcPos = (o.screenPos / o.screenPos.w) * 2 - 1;

    // Camera parameter
    float far = _ProjectionParams.z;

    // View space vector pointing to the far plane
    float3 clipVec = float3(ndcPos.x, ndcPos.y, 1.0) * far;
    o.viewVec = mul(unity_CameraInvProjection, clipVec.xyzz).xyz;

    return o;
}

sampler2D _CameraDepthTexture;

half4 frag(v2f i) : SV_Target
{
    // Sample the depth texture to get the linear 01 depth
    float depth = UNITY_SAMPLE_DEPTH(tex2Dproj(_CameraDepthTexture, i.screenPos));
    depth = Linear01Depth(depth);

    // View space position
    float3 viewPos = i.viewVec * depth;

    // Pixel world position
    float3 worldPos = mul(UNITY_MATRIX_I_V, float4(viewPos, 1)).xyz;

    return float4(worldPos, 1.0);
}

2. 世界空间方法

#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

struct v2f
{
    float4 vertex : SV_POSITION;
    float4 screenPos : TEXCOORD0;
    float3 worldSpaceDir : TEXCOORD1;
    float viewSpaceZ : TEXCOORD2;
};

v2f vert(appdata_base v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);

    // World space vector from camera to the vertex position
    o.worldSpaceDir = WorldSpaceViewDir(v.vertex);

    // Z value of the vector in view space
    o.viewSpaceZ = mul(UNITY_MATRIX_V, float4(o.worldSpaceDir, 0.0)).z;

    // Compute texture coordinate
    o.screenPos = ComputeScreenPos(o.vertex);
    return o;
}

sampler2D _CameraDepthTexture;

half4 frag(v2f i) : SV_Target
{
    // Sample the depth texture to get the linear eye depth
    float eyeDepth = UNITY_SAMPLE_DEPTH(tex2Dproj(_CameraDepthTexture, i.screenPos));
    eyeDepth = LinearEyeDepth(eyeDepth);

    // Rescale the vector
    i.worldSpaceDir *= -eyeDepth / i.viewSpaceZ;

    // Pixel world position
    float3 worldPos = _WorldSpaceCameraPos + i.worldSpaceDir;

    return float4(worldPos, 1.0);
}

3. 正交摄像机的情况

#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

struct v2f
{
    float4 vertex : SV_POSITION;
    float4 screenPos : TEXCOORD0;
    float3 viewVec : TEXCOORD1;
};

v2f vert(appdata_base v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);

    // Compute texture coordinate
    o.screenPos = ComputeScreenPos(o.vertex);

    // NDC position
    float4 ndcPos = (o.screenPos / o.screenPos.w) * 2 - 1;

    // View space vector from near plane pointing to far plane
    o.viewVec = float3(unity_OrthoParams.xy * ndcPos.xy, 0);

    return o;
}

sampler2D _CameraDepthTexture;

half4 frag(v2f i) : SV_Target
{
    // Camera parameters
    float near = _ProjectionParams.y;
    float far = _ProjectionParams.z;

    // Sample the depth texture to get the linear depth
    float rawDepth = UNITY_SAMPLE_DEPTH(tex2D(_CameraDepthTexture, i.screenPos));
    float ortho = (far - near) * (1 - rawDepth) + near;
    float depth = lerp(LinearEyeDepth(rawDepth), ortho, unity_OrthoParams.w) / far;

    // Linear interpolate between near plane and far plane by depth value
    float z = -lerp(near, far, depth);

    // View space position
    float3 viewPos = float3(i.viewVec.xy, z);

    // Pixel world position
    float3 worldPos = mul(UNITY_MATRIX_I_V, float4(viewPos, 1)).xyz;

    return float4(worldPos, 1.0);
}

注脚

Footnotes

  1. w is 1/FarPlane.

  2. Unity's convention, where forward is the positive Z axis

  3. CorrectDepth