阴影偏移只加常量:斜面仍会自遮挡,平面先漂起来

同一盏方向光下,水平地面已经没有阴影痤疮,倾斜屋顶仍布满自遮挡条纹。继续增大全局 Depth Bias 确实能让条纹消失,角色脚下和墙角的接触阴影却开始悬空。这个现象说明偏移量里混着两类不同尺度:深度表示本身的离散步长,以及三角形深度沿屏幕像素变化的斜率。

本文把问题压缩成两个深度平面。近水平样本的导数为 dz/dx=0.00002dz/dy=-0.00001,接收端差值是 0.000025;掠射样本的最大导数升到 0.00125,差值为 0.00045。两者使用同一份 16 位归一化深度和同一常量配置,差异只来自斜率。

石阶与斜坡上的接触阴影展示偏移不足和偏移过量的可见差异

常量项只能回答深度格有多细

16 位归一化深度在这份探针中用 1/65536 作为一个离散步长。常量单位设为 2,得到 0.0000305176:它大于平面样本的 0.000025,所以能够把写入深度和接收深度分开;面对斜面的 0.00045,同一个数只剩十四分之一,继续把它称为“通用安全距离”已经失去量纲依据。

斜率项取 max(|dz/dx|, |dz/dy|),再乘配置系数。当前系数为 0.5,掠射样本得到 0.000625;加上常量项后总量是 0.000655518,超过固定误差样本。核心实现没有依赖具体渲染 API,它只把配置、深度格式和当前平面的导数收拢为一个可测试策略:

public readonly record struct DepthPlane(double DzDx, double DzDy)
{
    public double MaxSlope => Math.Max(Math.Abs(DzDx), Math.Abs(DzDy));
}

public readonly record struct BiasPolicy(double ConstantUnits, double SlopeScale, int DepthBits)
{
    public double DepthStep => 1.0 / (1L << DepthBits);
}

public readonly record struct BiasResult(double ConstantTerm, double SlopeTerm, double Total);

public static class ShadowBias
{
    public static BiasResult Evaluate(DepthPlane plane, BiasPolicy policy)
    {
        if (!double.IsFinite(plane.DzDx) || !double.IsFinite(plane.DzDy))
            throw new ArgumentOutOfRangeException(nameof(plane));
        if (policy.ConstantUnits < 0 || policy.SlopeScale < 0)
            throw new ArgumentOutOfRangeException(nameof(policy));
        if (policy.DepthBits is < 8 or > 30)
            throw new ArgumentOutOfRangeException(nameof(policy));

        double constant = policy.ConstantUnits * policy.DepthStep;
        double slope = policy.SlopeScale * plane.MaxSlope;
        return new BiasResult(constant, slope, constant + slope);
    }

    public static bool Separates(double receiverDelta, BiasResult bias)
    {
        if (!double.IsFinite(receiverDelta) || receiverDelta < 0)
            throw new ArgumentOutOfRangeException(nameof(receiverDelta));
        return bias.Total >= receiverDelta;
    }
}

这里的 DepthStep 是研究探针的明确模型,不是对所有 GPU 深度格式和 API 光栅化规则的替代描述。实际后端如何把常量单位映射到深度值、浮点深度是否随指数变化、斜率钳制何时生效,都应由渲染后端适配层负责。上层材质或灯光配置只提交有物理意义的政策参数,不能各自复制一套换算。

平面和掠射样本的常量项、斜率项与固定差值对照

把常量调到能救斜面,会先破坏接触关系

若不用斜率项,只想用常量覆盖 0.00045,16 位样本至少需要约 30 个单位。探针另外检查 64 单位,结果为 0.000976563,已经远高于平面样本的误差需求。这个数不会因为表面变平而自动收缩,因此最先暴露的不是斜面,而是本来已经稳定的接触阴影:阴影投射深度被整体推远,接收者与投射者之间出现可见间隔。

斜率偏移也不是越大越好。它随屏幕空间导数增长,会在掠射面和低分辨率阴影图上迅速放大;模型很薄、阴影级联跨度很大或法线与实际几何面不一致时,仍可能产生脱离。偏移政策还必须跟随阴影贴图配置一起版本化:分辨率、深度格式或投影范围变化后,旧常量对应的深度尺度已经改变。若资源重建和参数更新分两帧生效,同一盏灯会短暂使用互不匹配的纹理与偏移。提交边界应一次替换阴影资源描述、投影参数和偏移政策,失败时继续保留上一份完整配置。可靠配置应分别限制常量项与斜率项,并用接触面和掠射面两类资产验收。需要更强控制时,可以在后端加入斜率钳制,或在生成阴影几何时使用法线方向偏移,但这些方案改变的是另一组几何与采样边界,不能用当前探针的数据直接宣称等价。

Program.Main 同时验证固定偏移成功、斜面固定偏移失败、组合偏移成功、过量常量越界,以及负配置、非有限导数和负误差拒绝。当天执行:

dotnet build "$LAB_DIR/ShadowBiasLab.csproj" -c Release
dotnet run --project "$LAB_DIR/ShadowBiasLab.csproj" -c Release --no-build

构建与运行退出码均为 0,构建零警告、零错误。决定性输出为:

passed=10 failed=0 skipped=0 elapsedMs=18.3121
depthStep=0.0000152587890625
flatConstant=0.000030517578125 flatDelta=0.000025 result=pass
grazingConstant=0.000030517578125 grazingDelta=0.00045 result=fail
grazingCombined=0.000655517578125 result=pass
excessiveFlat=0.0009765625

这些数字验证的是固定 CPU 模型和所列样本,不是 GPU 性能测试,也不是任意场景的偏移上界。探针没有光栅化真实三角形、执行 PCF、覆盖反向 Z 或测量不同厂商的深度实现;它锁住的是参数职责:常量项补偿表示尺度,斜率项响应表面深度变化,两者必须能独立调节和审查。

回到开头的屋顶与地面,只增大全局常量会让两个问题争抢同一个旋钮。把最大深度导数带进偏移后,掠射面得到额外余量,近平面仍保持较小基线。接入具体引擎时,应先在渲染后端确认 API 符号、单位与深度方向,再用这两类固定样本校验映射;否则参数名称相同,也可能把偏移施加到相反方向。