Deep Read

深度缓冲反推世界空间坐标

从深度缓冲反推世界坐标是很多屏幕空间效果(体积光、屏幕空间反射、贴花、水面等)的基础。核心问题是:屏幕上一个像素只带一个深度值,如何把它还原成三维世界里的一个点。下面介绍两种常用方法。

NDC 空间反推

渲染管线的坐标变换流程是:模型空间 → 世界空间 → 观察空间 → 裁剪空间 → NDC 空间(正向流程的推导见 【空间变换】投影变换【空间变换】投影变换经过 [[【空间变换】观察变换]],顶点已经来到以相机为原点的观察空间。投影变换要做的,是把观察空间里相机能看到的那块区域,映射到一个规整的立方体里,方便后续裁剪。这一步的矩阵叫裁剪矩阵,也叫投影矩阵。本篇沿用 Games101 的思路推导。 先把结论列在前面,推完再回看会更清楚: - 把顶点变换到裁剪空间的目的,是方便做裁剪。 - 正交相机的裁剪空间,就是一个标准立方体(CVV)。 - 推导透视投影时,我们会乘上一个观察空间的 $z$ 值($z_{view}$),这个 $z$ 最终成为裁剪空间的 $w$ 分量。 - 透视相机的裁剪,是判断坐标 $(x, y, z)$ 是否落在 $(-w, w)$ 之内。 裁剪空间 顶点从观察空间变换到裁剪空间(Clip Space,也叫齐次裁剪空间),用的就是投影矩阵。之所以要这块空间,是为了方便对图元做裁剪: - 完全落在这块空间内部的图元,保留; - 完全落在外部的,剔除; - 与边界相交的,裁剪掉超出的部分。 这块空间由视锥体(View Frustum) 决定——它是空间中一块由六个裁剪平面(Clip Planes)围成的区域,界定【空间变换】屏幕映射(Screen Mapping)【空间变换】屏幕映射(Screen Mapping)经过 [[【空间变换】投影变换]],顶点已经在裁剪空间里了。屏幕映射是这条管线的最后一站,负责把裁剪空间的顶点落到屏幕上具体的像素位置。它包含两步: - 透视除法(Perspective Division) - 视口变换(Viewport Transform) 透视除法 image.png 上图是顶点数据的完整变换流程。局部空间的顶点,依次经过模型矩阵 Model、观察矩阵 View、投影矩阵 Projection,来到裁剪空间,此时满足 $-w_{clip} \leq x, y, z \leq w_{clip}$。接着做透视除法——每个分量都除以 $w$ 分量——就得到了标准设备坐标(NDC),它所在的空间也叫标准视体(Canonical View Volume,CVV)。 为什么这一步能实现近大远小? 关键在 $w$。回顾 [[【空间变换】投影变换]] 的推导,投影矩阵刻意把观察空间的 $z$ 坐标塞进了裁剪空间的 $w$ 分量。于是透视除法(除以 $w$,等于除以 $z$)就让离相机越远、$z$ 越大的顶点,坐标被压缩得越狠——这正是透视效果的来源。这也回扣了 [[【空)。要从深度反推世界坐标,就得逆转这个流程:NDC → 裁剪 → 观察 → 世界。最直接高效的做法是利用**观察投影矩阵的逆矩阵(Inverse View-Projection Matrix)**一步到位,从裁剪空间直接回到世界空间。

这里以远裁剪面为基准推导。

重建 NDC 坐标

先把屏幕空间坐标转换到 NDC 空间:

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

再把屏幕像素对应在相机远平面(Far plane)上的点转换到裁剪空间。因为 NDC 空间中远平面上点的 z 分量为 1,直接乘以相机的 Far 值即可转换到裁剪空间(本质就是反向透视除法):

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

接着用逆投影矩阵把点转换到观察空间:

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

观察空间中相机位置一定是 (0,0,0),所以从相机指向远平面上点的向量,就是该点在观察空间中的位置。把这个向量乘以线性深度值,就得到深度缓冲中该像素对应的观察空间位置:

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;

用逆 View-Projection 一步到位

如果直接用逆 View-Projection 矩阵从 NDC 变换到世界空间,得到的是带 w 分量的齐次坐标,必须再做一次除以 w 才能还原为真正的 3D 坐标:

float4 worldPosHomogeneous = mul(invViewProj, positionNDC);
worldPosHomogeneous.xyz /= worldPosHomogeneous.w;

下面是一个从深度重建观察空间、再转到世界/物体空间的完整示例:

half2 GetObjectSpeceByDepth(half2 positionCS, half3 positionVS)
{
    half2 screenUV = positionCS.xy / _ScreenParams.xy;
    half depthMap = SAMPLE_TEXTURE2D(_CameraDepthTexture, sampler_CameraDepthTexture, screenUV).r;
    half depth = LinearEyeDepth(depthMap, _ZBufferParams);

    // 深度重建观察空间
    float4 depthVS = 1;
    depthVS.z = depth;
    depthVS.xy = positionVS * depth / - positionVS.z;

    // 将观察空间深度转换为世界空间
    float3 depthWS = mul(unity_CameraToWorld, depthVS).xyz;
    float3 depthOS = mul(unity_WorldToObject, float4(depthWS, 1.0)).xyz;
    return depthOS.xz;
}

相机射线法

这种方法以近裁剪面为基准,根据观察空间深度值计算,通常比逐像素做矩阵乘法更省。

实现原理

任何从相机发出、最终落在屏幕上的光线,都必然穿过近裁剪面。因此,从相机出发穿过某个像素的射线,可以由穿过视锥体四个近裁剪面角的四条射线,按该像素在屏幕上的相对位置(UV 坐标)做双线性插值得到。

一个点的世界坐标,可以用相机位置加上该点相对相机的偏移量求得。也就是说,只需知道相机在世界空间的位置,以及该像素相对相机的世界空间偏移,两者相加即可:

worldPos = WorldSpaceCameraPos + LinearDepth · interpolatedRay
  • WorldSpaceCameraPos:相机在世界空间的位置。
  • LinearDepth · interpolatedRay:世界空间偏移量。
    • LinearDepth:线性深度值。
    • interpolatedRay:由顶点着色器输出、插值得到的射线,包含该像素到相机的距离与方向信息。

之所以能这样简化,是因为把每条角射线按 magnitude / near 缩放后,射线的长度恰好等于"观察空间深度为 1 时的位移",于是乘以线性深度就直接得到偏移量,无需逐像素矩阵变换。

步骤

计算四角射线方向(C# 侧):

var camera = renderingData.cameraData.camera;
var cameraData = renderingData.cameraData;
var cameraTransform = camera.transform;

Matrix4x4 frustumCorners = Matrix4x4.identity;

float fov = camera.fieldOfView;
float near = camera.nearClipPlane;
float aspect = camera.aspect;

float halfHeight = near * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad);

Vector3 toRight = cameraTransform.right * halfHeight * aspect;
Vector3 toTop = cameraTransform.up * halfHeight;
Vector3 topLeft = cameraTransform.forward * near + toTop - toRight;

float scale = topLeft.magnitude / near;
topLeft.Normalize();
topLeft *= scale;

Vector3 topRight = cameraTransform.forward * near + toRight + toTop;
topRight.Normalize();
topRight *= scale;

Vector3 bottomLeft = cameraTransform.forward * near - toRight - toTop;
bottomLeft.Normalize();
bottomLeft *= scale;

Vector3 bottomRight = cameraTransform.forward * near + toRight - toTop;
bottomRight.Normalize();
bottomRight *= scale;

frustumCorners.SetRow(0, bottomLeft);
frustumCorners.SetRow(1, bottomRight);
frustumCorners.SetRow(2, topRight);
frustumCorners.SetRow(3, topLeft);

material.SetMatrix("_FrustumCornersRay", frustumCorners);
cmd.Blit(source, destination, material, 0);

采样深度、确定距离:

half depthMap = SAMPLE_TEXTURE2D(_CameraDepthTexture, sampler_CameraDepthTexture, i.uv_depth);
half linearDepth = LinearEyeDepth(depthMap, _ZBufferParams);

最终合成世界坐标:

half3 positionWS = _WorldSpaceCameraPos + linearDepth * i.interpolatedRay.xyz;