· 5 分钟阅读 · 1752 字

阴影图集边缘突然变暗:PCF 卷积核不能越过瓦片所有权

阴影级联的投影矩阵、分割距离和深度比较都没有变化,画面边缘却会混入相邻级联的一条暗带。这类缺陷容易被归到偏移量或级联选择,实际读取错误可能发生得更晚:中心采样仍在当前瓦片,PCF 的邻域偏移已经跨过图集分界线,读到了另一张深度图。

手绘阴影图集与跨瓦片采样点回夹示意

本文把问题限定为图集中的手动点比较 PCF。固定图集为 8×4 纹素,左侧瓦片占 x=0..3, y=0..3,3×3 卷积核半径为 1。接收点位于左瓦片最后一列纹素中心,局部 UV 为 (0.875, 0.5)。若直接在图集坐标上加偏移,三次 x=4 采样会进入右侧瓦片;深度比较本身完全合法,所以错误不会以异常或无效值暴露。

瓦片拥有的是纹素中心范围

图集矩形常按几何边界保存为 [min, min+size]。若物理纹素边界坐标为 c,可供点采样的中心范围是 [min+0.5, min+size-0.5],归一化坐标为 c/atlasSize;若使用整数纹素索引 i,对应中心才写成 (i+0.5)/atlasSize。把 UV 只钳制到瓦片几何边界仍然太宽:硬件双线性比较采样在边界上会展开到两侧纹素,足迹仍可能进入邻接瓦片。源码中的 centerX、centerY 工作在整数中心索引空间,最后钳制的是索引而非已经加过半纹素的物理坐标。

研究探针的入口是 $LAB_ROOT/ShadowAtlasPcf.cs 中的 BuildKernel。它先把瓦片局部 UV 换成连续纹素中心,使用 floor(value+0.5) 定义半开最近点选择,再对每个偏移独立钳制。图集尺寸、瓦片范围、UV 和半径都在生成采样前验证,非法瓦片不会产出半份卷积核。

public readonly record struct Int2(int X, int Y);
public readonly record struct Tap(int X, int Y);

public readonly record struct TileRect(int MinX, int MinY, int Width, int Height)
{
    public int MaxX => checked(MinX + Width - 1);
    public int MaxY => checked(MinY + Height - 1);
}

public static class ShadowAtlasPcf
{
    public static Tap[] BuildKernel(
        Int2 atlasSize,
        TileRect tile,
        double localU,
        double localV,
        int radius,
        bool clampToTile)
    {
        Validate(atlasSize, tile, localU, localV, radius);

        var centerX = tile.MinX + localU * tile.Width - 0.5;
        var centerY = tile.MinY + localV * tile.Height - 0.5;
        var taps = new Tap[checked((radius * 2 + 1) * (radius * 2 + 1))];
        var index = 0;

        for (var y = -radius; y <= radius; y++)
        for (var x = -radius; x <= radius; x++)
        {
            var tapX = (int)Math.Floor(centerX + x + 0.5);
            var tapY = (int)Math.Floor(centerY + y + 0.5);

            if (clampToTile)
            {
                tapX = Math.Clamp(tapX, tile.MinX, tile.MaxX);
                tapY = Math.Clamp(tapY, tile.MinY, tile.MaxY);
            }

            taps[index++] = new Tap(tapX, tapY);
        }

        return taps;
    }

    private static void Validate(
        Int2 atlasSize,
        TileRect tile,
        double localU,
        double localV,
        int radius)
    {
        if (atlasSize.X <= 0 || atlasSize.Y <= 0)
            throw new ArgumentOutOfRangeException(nameof(atlasSize));
        if (tile.Width <= 0 || tile.Height <= 0 || tile.MinX < 0 || tile.MinY < 0 ||
            tile.MaxX >= atlasSize.X || tile.MaxY >= atlasSize.Y)
            throw new ArgumentOutOfRangeException(nameof(tile));
        if (!double.IsFinite(localU) || !double.IsFinite(localV) ||
            localU < 0 || localU > 1 || localV < 0 || localV > 1)
            throw new ArgumentOutOfRangeException(nameof(localU));
        if (radius < 0 || radius > 8)
            throw new ArgumentOutOfRangeException(nameof(radius));
    }
}

这段 CPU 实现显式生成整数采样点,用于验证所有权,不声称替代具体 Shader。若后端使用硬件双线性比较采样,钳制对象应是每次 tap 的连续采样中心,并为半纹素足迹留界;若使用更大的各向异性或自定义过滤足迹,保护范围还要随实际核半径扩大。

阻断串读不等于保留原卷积核

固定输入的朴素结果为三列 x=2,3,4。钳制后变成 x=2,3,3,三行均重复最后一列;串读从 3 次降为 0,但边缘纹素的权重从一次增加到两次。这种边缘复制适合把“相邻瓦片的深度”排除出结果,却改变了原本对称的卷积分布,可能让瓦片边缘的软阴影形状收缩。

探针把左瓦片全部纹素深度设为 1、右瓦片设为 0,接收深度为 0.5,并用 receiverDepth <= storedDepth 做点比较。朴素卷积的六个左瓦片样本通过、三个邻瓦片样本失败,可见度为 6/9=0.666667;钳制后的九个样本全部通过,可见度为 1。这组数值只隔离串读对结果的影响,不代表真实阴影内容必然是全亮或全暗。

固定图集下朴素采样、纹素中心钳制与保护带取舍

需要保留真实 3×3 邻域时,分配器可以给每个瓦片增加至少一个纹素的保护带,并让本级联投影实际渲染这圈扩大的几何覆盖;5×5 核则至少需要两个纹素。只把边缘深度复制进保护区,与钳制一样重复边缘值,只能隔离存储足迹,不能恢复投影范围外缺失的几何。另一种方案是缩小级联的有效接收范围,让边缘只承担过滤保护带;两种做法都会改变覆盖和切换带,必须与级联选择共同设计。

还有一条边界不能由钳制掩盖:投影坐标已经落到级联覆盖范围之外时,正确动作是选择另一层级联或判定该阴影无效,不能把任意坐标吸到边缘纹素。钳制只约束“有效接收点的过滤足迹”,不负责修复错误的级联索引、投影矩阵或深度偏移。

同一失败现象由两道检查分开

$LAB_ROOT/Program.cs 包含七组测试:固定边缘串读、钳制后所有权、邻瓦片深度导致变暗、内部核不变、非法瓦片拒绝、UV 两端与 1×1 瓦片,以及固定种子 10,000 组随机对照。随机部分同时执行逐点参照和所有权性质检查;它验证当前整数模型,不是 GPU 画质或耗时基准。以下数据来自 2026-11-07 当天的 Release 构建与运行。

$ dotnet restore ShadowAtlasPcfLab.csproj -p:NuGetAudit=false
$ dotnet build ShadowAtlasPcfLab.csproj -c Release --no-restore --nologo
$ dotnet run --project ShadowAtlasPcfLab.csproj -c Release --no-build

Build succeeded. 0 Warning(s), 0 Error(s)
PASS edge kernel crosses adjacent tile
PASS tile clamp keeps all taps in owner
PASS neighbor depth darkens naive visibility
PASS interior kernel remains unchanged
PASS invalid tile is rejected before sampling
PASS endpoints and one texel tile stay owned
PASS randomized reference agrees
tests=7 passed=7 failed=0 skipped=0 elapsedMs=216.390
randomCases=10000 disagreements=0
fixed naiveOutside=3 clampedOutside=0
fixed naiveVisibility=0.666667 clampedVisibility=1.000000
exitCode=0

图集管理器因此需要向阴影采样端同时交付瓦片矩形、图集尺寸、有效投影范围和过滤策略,而不只是一个缩放偏移。采样端可用半纹素中心钳制建立最低所有权门禁;需要真实邻域时,级联投影与图集分配必须共同扩大覆盖并渲染保护带。两种策略解决的层次不同:前者禁止读到别人的深度,后者让卷积核读到属于本级联的真实几何。