相机只移动九毫米,静态阴影为什么会闪

相机沿地面移动 9 毫米,塔楼、太阳和地形都没有变化,墙脚的阴影边缘却在屏幕上明暗跳动。这个现象容易被归到 PCF 核、深度偏移或阴影分辨率,但还有一个更早的变量:方向光正交投影的中心也跟着相机连续移动了。

静态世界点因此落到阴影贴图的不同小数 texel 位置。即使移动量小于一个 texel,后续深度比较与滤波仍会读取另一组权重。提高分辨率只能缩短世界空间中的 texel 宽度,不能消除投影矩阵每帧连续漂移。要稳定的是世界到阴影贴图的映射,而不是某一个滤波参数。

方向光级联投影与稳定阴影网格

九毫米怎样变成 0.2304 个 texel

探针固定一个直径 80 米的正交级联,阴影贴图分辨率为 2048。一个 texel 覆盖的世界距离为:

metersPerTexel = cascadeDiameter / resolution
               = 80 / 2048
               = 0.0390625 m

light-space X 中心从 12.001 m 移到 12.010 m,投影中心变化 0.009 m。未量化时,阴影贴图中的位移是 0.009 / 0.0390625 = 0.2304 texel。它没有跨过完整像素,却足以改变双线性采样或 PCF 的混合比例。

对齐规则是在 light space 中把中心取整到 texel 网格:

snapped = floor(center / metersPerTexel + 0.5) * metersPerTexel

两个输入都得到 11.9921875 m。相机确实移动了,但这一小段移动不再改写阴影贴图的采样原点;当中心跨过量化边界时,投影才以一个完整 texel 为单位前进。离散跳变仍然存在,不过它与阴影贴图的像素格同相,不会让静态点在像素内部持续游走。

未对齐漂移与 texel 对齐后的固定映射

对齐发生在 light space,不在世界坐标轴上

最小实现位于 .tmp/daily-labs/2026-08-30-2000/Program.csTryUpdate 接收已经变换到方向光平面的中心,Project 用当前级联直径与分辨率计算步长,再分别量化 X、Y:

readonly record struct LightPoint(double X, double Y)
{
    public bool IsFinite => double.IsFinite(X) && double.IsFinite(Y);
}

readonly record struct CascadeConfig(
    long Revision,
    double DiameterMeters,
    int Resolution);

readonly record struct StableProjection(
    long ConfigRevision,
    LightPoint RequestedCenter,
    LightPoint SnappedCenter,
    double MetersPerTexel);

sealed class StableShadowCascade
{
    private CascadeConfig config;
    private StableProjection active;

    public StableShadowCascade(double diameterMeters, int resolution)
    {
        config = new CascadeConfig(1, diameterMeters, resolution);
        active = Project(config, new LightPoint(0, 0));
    }

    public StableProjection Active => active;

    public ProjectionResult TryUpdate(LightPoint requestedCenter)
    {
        var candidate = Project(config, requestedCenter);
        if (!requestedCenter.IsFinite)
            return ProjectionResult.InvalidCenter;

        active = candidate;
        return ProjectionResult.Committed;
    }

    public ProjectionResult TryReconfigure(CascadeConfig candidate)
    {
        if (candidate.Revision <= config.Revision)
            return ProjectionResult.StaleConfig;
        if (!double.IsFinite(candidate.DiameterMeters) ||
            candidate.DiameterMeters <= 0)
            return ProjectionResult.InvalidDiameter;
        if (candidate.Resolution < 2 ||
            (candidate.Resolution & (candidate.Resolution - 1)) != 0)
            return ProjectionResult.InvalidResolution;

        config = candidate;
        active = Project(config, active.RequestedCenter);
        return ProjectionResult.Committed;
    }

    private static StableProjection Project(
        CascadeConfig value,
        LightPoint requested)
    {
        var step = value.DiameterMeters / value.Resolution;
        var snapped = new LightPoint(
            Snap(requested.X, step),
            Snap(requested.Y, step));
        return new StableProjection(
            value.Revision,
            requested,
            snapped,
            step);
    }

    private static double Snap(double value, double step) =>
        Math.Floor(value / step + 0.5) * step;
}

这里不能在世界 XZ 平面直接取整。方向光旋转后,阴影贴图的 U、V 轴通常不再与世界轴重合;世界轴对齐仍会让 light-space 坐标留下小数 texel。实际渲染器应先确定覆盖该级联的 light-space 正交包围范围,再量化其中心,最后由量化后的中心重建 View-Projection 矩阵。

级联直径也必须保持稳定。若每帧用当前可见物包围盒重新收紧直径,metersPerTexel 会随内容改变;中心即使落在网格点,网格自身仍在缩放。实践中可以让每级联使用固定直径,或把直径也离散到稳定档位。本文探针只验证固定直径下的中心对齐,没有把动态包围策略包装成已解决问题。

还有一个容易漏掉的实现细节:量化值必须参与真正提交给 GPU 的矩阵构造,而不能只保存在调试字段中。若剔除使用量化后的范围,Shader 常量却仍来自原始中心,CPU 看到的级联覆盖与 GPU 使用的采样映射会分叉。相反,只改 Shader 矩阵而让剔除范围停在连续中心,也可能在量化边缘漏掉本应投影的施法者。生产接入时应从同一个 StableProjection 同时派生剔除体、阴影相机矩阵和采样常量,并在渲染帧边界一次替换。

错误分辨率不能悄悄改变量化网格

对齐公式依赖 DiameterMetersResolution 同时有效。探针把配置作为带修订的整体检查:直径必须为正且有限,分辨率必须是不小于 2 的二次幂,修订必须前进。测试故意提交分辨率 1500;结果返回 InvalidResolution,活动修订仍为 1,分辨率仍为 2048。

二次幂不是数学公式的必要条件,而是当前渲染管线的资源合同。若目标后端明确支持非二次幂阴影图,可以放宽这条检查;关键是资源尺寸、texel 步长与投影矩阵不能分别在不同位置生效。否则资源已经换成新尺寸,投影仍按旧步长量化,得到的不是渐进退化,而是两套采样合同混在同一帧。

当天验证命令与决定性输出如下:

cd "$WORK_DIR/.tmp/daily-labs/2026-08-30-2000"
dotnet build ShadowTexelLab.csproj -c Release --no-restore
dotnet run --project ShadowTexelLab.csproj -c Release --no-build
Build succeeded.
    0 Warning(s)
    0 Error(s)
normal texel_m=0.03906250 requested_shift_texels=0.2304 snapped_shift_texels=0.0 snapped_x=11.99219
boundary invalid_resolution=InvalidResolution active_revision_before=1 active_revision_after=1 active_resolution=2048
tests pass=8 fail=0 skip=0 duration_ms=15.29

这组结果只证明固定输入下的映射与失败保持,不代表 GPU 耗时或最终画质已经改善。接收者偏移、斜率偏移、PCF 核、级联交界混合和旋转光源仍可能制造阴影闪烁;光源方向变化时,light-space 基底本身改变,中心量化也无法维持完全相同的映射。

回到开头的九毫米位移,问题不在相机移动得太小,而在连续投影把这段运动变成了阴影贴图内的分数像素漂移。固定级联覆盖尺度、在 light space 量化中心,并让配置与矩阵使用同一份有效状态,能够消除这一类静态场景抖动。它适用于方向光正交级联且光源基底稳定的区间;超出这个边界,就应继续检查级联尺寸变化、光源旋转和深度比较链,而不是继续提高阴影分辨率。