反向 Z 深度重建:清屏值零不能直接还原成有限距离

画面切换到反向 Z 后,几何阶段通常很快就能恢复:清深度改为零,比较函数改为 Greater,投影矩阵也随之调整。更隐蔽的错误会留在雾、屏幕空间描边、接触阴影和位置重建里。它们仍把深度纹理当作普通 Z,于是一个离相机 1000 米的像素,可能被解释成近面旁边的 0.100010 米。

相机视锥内的反向 Z 深度层分布

问题不在采样 API,而在生产深度与消费深度的代码使用了两份投影合同。本文固定右手视空间中的正距离 z,近面为 n,设备深度范围为 [0,1]。无限远反向 Z 的关系是 d=n/z:近面写入 1,距离增大时趋近 0。后处理要恢复有限距离,只需 z=n/d,但这个除法只对 0<d<=1 成立。

一条公式错误会把远景压回近面

若投影仍有有限远面 f,反向 Z 写入值为:

d = n(f-z) / ((f-n)z)
z = nf / (n + d(f-n))

把普通 Z 的 z=nf/(f-d(f-n)) 原样套在反向深度上,分母的结构已经错了。固定输入 n=0.1 米、f=10000 米、z=1000 米时,无限远投影给出 d=0.0001。正确公式还原为 1000 米,普通 Z 公式却返回 0.100010 米。雾密度、世界位置和基于距离的阈值都会因此把远景当成贴着相机的表面。

下面的实现把无限远与有限远投影分开,并把可接受的数值域放在接口边界。源码路径为 ReverseZDepth.cs,调用入口在 Program.cs

namespace DepthLab;

public readonly record struct DepthSample(double ViewDistance, double DeviceDepth);

public static class ReverseZDepth
{
    public static bool TryProjectInfinite(double near, double viewDistance, out double depth)
    {
        depth = 0;
        if (!ValidPositive(near) || !ValidPositive(viewDistance) || viewDistance < near)
            return false;
        depth = near / viewDistance;
        return double.IsFinite(depth) && depth is > 0 and <= 1;
    }

    public static bool TryReconstructInfinite(double near, double depth, out double viewDistance)
    {
        viewDistance = 0;
        if (!ValidPositive(near) || !double.IsFinite(depth) || depth is <= 0 or > 1)
            return false;
        viewDistance = near / depth;
        return double.IsFinite(viewDistance) && viewDistance >= near;
    }

    public static bool TryProjectFinite(double near, double far, double viewDistance, out double depth)
    {
        depth = 0;
        if (!ValidFrustum(near, far) || !ValidPositive(viewDistance) || viewDistance < near || viewDistance > far)
            return false;
        depth = near * (far - viewDistance) / ((far - near) * viewDistance);
        return double.IsFinite(depth) && depth is >= 0 and <= 1;
    }

    public static bool TryReconstructFinite(double near, double far, double depth, out double viewDistance)
    {
        viewDistance = 0;
        if (!ValidFrustum(near, far) || !double.IsFinite(depth) || depth is < 0 or > 1)
            return false;
        viewDistance = near * far / (near + depth * (far - near));
        return double.IsFinite(viewDistance) && viewDistance is >= 0;
    }

    public static double ReconstructWithStandardZFormula(double near, double far, double depth) =>
        near * far / (far - depth * (far - near));

    private static bool ValidFrustum(double near, double far) =>
        ValidPositive(near) && ValidPositive(far) && far > near;

    private static bool ValidPositive(double value) => double.IsFinite(value) && value > 0;
}

有限远公式允许 d=0,因为它明确对应远面 f。无限远版本拒绝零:此时数学距离趋向无穷,而实际深度纹理中的零还常常是清屏后没有几何覆盖的背景。若函数擅自返回一个巨大浮点数,后续效果就失去了区分天空、空像素与极远几何的机会。更稳妥的接口应返回失败、可空位置,或让调用者先依据深度判断背景。

这个区别会直接改变全屏 Pass 的分支位置。若效果只需要判断几何是否存在,应在做除法前检查清屏值;若效果允许天空参与,则应为天空提供独立方向或颜色输入,而不是伪造视距。有限远相机则可以把零解释为远面,但调用方仍要知道该像素是恰好落在远面,还是没有被几何写入。仅有一个浮点深度无法恢复这种覆盖信息,需要模板、对象标记或另一份有效性通道时,应在渲染图设计阶段明确提供。

还要避免把反向 Z 简化成“把深度比较翻过来”。比较函数只决定哪个片元保留,重建代码关心的则是保留下来的数值如何映射回视空间。两者由同一投影产生,却位于不同 Pass,最容易在相机模块升级后只改到一侧。将近面、远面模式和重建常量散落在材质里,会让离屏相机、反射相机和主相机各自形成隐含约定;同一张深度纹理一旦被错误的常量消费,结果仍可能是有限数值,因此普通的非空检查发现不了问题。

反向 Z 映射、公式误用结果与重建输入合同

深度重建必须跟着投影参数一起提交

探针同时验证有限远与无限远往返、普通公式回归、零与负深度、无效视锥和非有限近面。执行命令如下,$LAB_ROOT 表示本文研究工程目录:

cd "$LAB_ROOT"
dotnet build DepthLab.csproj -c Release --nologo
dotnet run --project DepthLab.csproj -c Release --no-build
build: exit=0 warnings=0 errors=0 elapsed=10.29s
run: exit=0
depthAt1000=0.0001000 correct=1000.000000 wrongStandard=0.100010
backgroundDepth=0 meaning=infinite-or-clear
passed=33 failed=0 skipped=0 samples=33 elapsedMs=2.1723

耗时包含断言与 JSON 写入,不是 GPU 性能数据。测试也没有覆盖具体引擎的矩阵主序、裁剪空间差异、投影抖动、MSAA 深度解析或着色器浮点精度;这些都应由接入层继续验证。

若后处理通过逆投影矩阵重建完整位置,公式形态会隐藏在矩阵乘法和齐次除法中,但合同没有消失。采样得到的设备深度必须先转换到该图形 API 与投影矩阵预期的裁剪空间范围,再与生成这张深度纹理的逆矩阵配对。把上一帧逆矩阵、另一只眼睛的矩阵或有限远矩阵用于当前无限远深度,仍会得到数值正常却空间错误的位置。时间抗锯齿抖动还要求明确使用抖动或非抖动投影:重建当前像素通常要匹配写入深度时的抖动矩阵,而跨帧重投影还需要上一帧对应矩阵与视口尺寸。

因此,适合提交给渲染图的最小数据不是裸矩阵引用,而是一份带修订号的相机深度参数。产生深度的 Pass 写入修订号,雾、描边和屏幕空间效果声明读取同一修订;尺寸、近面、远面模式或深度范围变化时整份替换。运行时未必需要真的把修订号上传到 GPU,但 CPU 侧可以在构图时拒绝不一致的资源绑定。只有在多个相机或历史帧并存时,这种显式配对才会把本来要靠画面猜测的错误提前变成可检查的配置失败。

真正需要绑定在一起的不是一个布尔型 reversedZ,而是深度范围、近远面、投影类型、清屏语义和重建函数。相机改变投影或动态切换深度约定时,这组参数应作为同一份帧数据提交给产生深度和消费深度的 Pass。开篇的远景错误由此变得可定位:先确认设备深度属于哪份合同,再决定能否还原有限距离;遇到零深度时保留背景语义,而不是制造一个看似有效的世界位置。