· 4 分钟阅读 · 1642 字

相机只走半个阴影纹素,静止墙面上的边缘为何还会爬动

角色和墙都没有移动,镜头只平移了 4 毫米,墙上的阴影边缘却在像素间缓慢爬动。提高阴影贴图分辨率会缩小抖动幅度,却不会消除成因:如果方向光的正交投影每帧连续跟随相机,那么同一个世界点落入阴影贴图的位置也会连续改变。深度比较、PCF 核与最终屏幕像素看到的是一张正在脚下滑动的采样网格。

相机平移而阴影采样网格保持对齐的工程手绘示意

这里把问题缩到一个级联在光空间 XY 平面的投影坐标,不模拟深度写入、偏移或 GPU 过滤。固定覆盖宽度为 20.48 米,贴图分辨率为 2048,因此一个阴影纹素覆盖 0.01 米。静态点位于光空间 x=12 米,投影中心从 10 移到 10.004 米。未对齐时,它的纹素坐标变化为:

((12 - 10.004) / 0.01) - ((12 - 10) / 0.01) = -0.4 texel

这 0.4 不是浮点噪声,而是投影定义本身要求采样网格移动。只要相机持续亚纹素平移,静态几何就持续穿过纹素边界。

方向光没有有限位置,决定阴影纹理朝向的是光空间基,决定纹素覆盖范围的是正交投影。这里的中心坐标必须在世界锚定的光空间中计算:基向量和原点不能先随相机平移再把相机归零,否则中心每帧都会变成 0,所谓吸附只是在吸附一个已经丢失世界相位的坐标。相机位置只负责选择覆盖区域;同一格内的相机位置应共享同一投影中心。

锁住的是采样网格,不是相机

研究探针 .tmp/daily-labs/2026-11-04-2000/ShadowTexelStabilizer.cs 先计算世界单位下的纹素宽度,再把投影中心吸附到最近格点。返回值同时保存中心与尺度,后续构造正交矩阵和换算采样坐标必须使用同一份结果。

public readonly record struct StabilizedProjection(
    double CenterX, double CenterY, double WorldUnitsPerTexel)
{
    public (double X, double Y) WorldToTexel(
        double worldX, double worldY, int resolution) =>
        ((worldX - CenterX) / WorldUnitsPerTexel + resolution * 0.5,
         (worldY - CenterY) / WorldUnitsPerTexel + resolution * 0.5);
}

public static class ShadowTexelStabilizer
{
    public static StabilizedProjection Stabilize(
        double centerX, double centerY,
        double coverageWidth, int resolution)
    {
        if (!double.IsFinite(centerX) || !double.IsFinite(centerY) ||
            !double.IsFinite(coverageWidth) ||
            coverageWidth <= 0 || resolution <= 0)
            throw new ArgumentException("Invalid shadow projection input");

        double texel = coverageWidth / resolution;
        if (!double.IsFinite(texel) || texel <= 0)
            throw new ArgumentException("Unrepresentable texel scale");

        double xUnits = centerX / texel;
        double yUnits = centerY / texel;
        if (!double.IsFinite(xUnits) || !double.IsFinite(yUnits))
            throw new ArgumentException("Unrepresentable projection center");

        return new StabilizedProjection(
            Math.Floor(xUnits + 0.5) * texel,
            Math.Floor(yUnits + 0.5) * texel,
            texel);
    }
}

中心移动 0.004 米时,两帧都吸附到 10 米,静态点的纹素坐标差变成 0。中心移动到 10.006 米时,吸附中心跨到 10.01 米,静态点恰好移动 -1 个纹素。连续的分数漂移被转换为离散的整数格变化,采样网格与静态场景之间不再出现亚纹素滑动。

测试入口是 $LAB_DIR/Program.cs 的 Program.Main,真实调用链为 Program.Main → ShadowTexelStabilizer.Stabilize → StabilizedProjection.WorldToTexel。Stabilize 拥有中心量化规则,WorldToTexel 只消费已经提交的中心和纹素尺度。若矩阵构造重新使用未经吸附的相机中心,而调试坐标使用返回值,数值检查会看似稳定,实际深度贴图仍会移动;中心、尺度与矩阵边界应作为同一份投影快照交付。

未对齐投影与纹素对齐投影的固定输入坐标差异

取最近格点不是唯一能稳定网格的舍入策略。向下取整也会产生固定格点,但在负坐标处有单向偏置:-0.004 米会落到 -0.01 米,而最近格点落到 0。最近格点把最大中心偏移限制为半个纹素,并使两侧覆盖损失对称。投影视锥必须为这半个纹素预留 guard band;如果原包围盒已经贴着级联边界,直接移动中心可能裁掉另一侧的投影体。

覆盖宽度一变,格尺也随之改变

对齐只在光空间基、分辨率和覆盖宽度保持不变时成立。若每帧用可见物包围盒重新收紧正交宽度,coverageWidth/resolution 会变化,旧格点和新格点不再属于同一把尺;中心即使再次舍入,静态点仍会获得新的纹素坐标。级联阴影应给每级稳定的覆盖尺度,并在该级自己的光空间平面独立对齐。方向光旋转同样会改变基向量,需要接受网格重建,或限制光源更新频率,不能把旋转误归因于中心吸附失效。

固定样本把覆盖宽度从 20.48 米改为 40.96 米后,每纹素尺度从 0.01 米变为 0.02 米;同一个静态点的纹素坐标立即变化 -100。若级联分割由每帧动态视距或可见物范围驱动,应把尺度量化到有限档位,或在档位切换时接受一次明确的网格重建。否则每帧都对齐到一张不同尺寸的网格,中心舍入并不能维持静态相位。

当天运行命令和决定性输出如下。测试包含固定正常样本、负坐标、覆盖宽度变化、非法数值与 10000 组可复算随机输入;CPU 探针只验证投影坐标合同,不声称完成了 GPU 画面性能或视觉测量。

$ dotnet run --project $LAB_DIR/ShadowTexelLab.csproj -c Release
exit=0, passed=10020, failed=0, skipped=0, elapsed=13.0316 ms
world_units_per_texel=0.01
unaligned_drift_texels=-0.39999999999994884
stabilized_drift_texels=0
crossed_bucket_drift_texels=-1
resized_point_shift_texels=-100
random_count=10000, stable_pairs=10000
max_snap_error_texels=0.49987498855362517

其中 $LAB_DIR 表示本文探针目录。

这项修正处理的是相机平移引起的阴影采样网格漂移,不处理深度偏移错误、PCF 噪声、级联接缝、遮挡物动画或光源旋转。架构上更关键的约束是:级联覆盖尺度属于稳定策略的一部分,不能由逐帧紧包围逻辑随意改写;投影中心、纹素尺度与矩阵构造则应由同一个结果对象交付。满足这些条件时,增加分辨率才是在提高空间精度,而不是用更小的纹素掩盖一张仍在移动的网格。