树叶纹理缩小后变稀:Alpha Test 覆盖率的修正与离散边界

树叶卡片离相机远了一些,枝叶就明显变稀。这里可以先排除颜色变暗,把问题缩成一张只有 Alpha 的小纹理:原图一半样本不透明,生成粗层后,固定裁剪阈值却让全部样本消失。平均值并没有算错;保存平均 Alpha 与保存通过裁剪的面积,本来就是两种不同要求。

叶片轮廓在细采样、粗采样与覆盖补偿后的概念对照

上图只说明轮廓变化,不是引擎运行截图。下面用 C# 资产处理探针隔离这个问题:输入为有限的归一化 Alpha,按不重叠的 2×2 方块等权平均;覆盖率定义为纹素中心通过阈值的数量比例。这个定义容易复算,但还不能代替屏幕像素上的覆盖率。

八个不透明样本,为什么一个也留不住

固定输入为下面的 4×4 网格。四个 2×2 块分别有 3、3、1、1 个不透明样本,粗层自然得到 [0.75,0.75,0.25,0.25]。材质阈值取 0.8,约定 alpha >= cutoff 保留,相等也通过。

1 1 | 1 1
1 0 | 1 0
----+----
1 0 | 1 0
0 0 | 0 0

source=8/16 raw=0/4 corrected=2/4

原图的平均值是 0.5,粗层的平均值仍是 0.5,然而覆盖率从 50% 变成了零。裁剪是一个非线性判断,不能把“先判断再统计”交换成“先平均再判断”。颜色过滤是否在线性光域、透明底色是否预乘,都不会自动修复这个计数变化;这里没有 RGB 参与运算。

给本层 Alpha 乘以 16/15 并夹到一以内,两个 0.75 恰好到达 0.8,两个 0.25 变为约 0.266667。粗层于是保留两个样本,恢复到 50%。这是在修改供裁剪使用的分布,平均 Alpha 也随之改变,不能宣称平均透明度同时守恒。

返回一个系数,还不足以说明修复成功

设本层有 N 个样本,缩放为 s,覆盖率是 C(s)=count(min(1,s*a[i]) >= t)/N。对正阈值 t 和非负 Alpha,它随 s 单调不减,却是一条阶梯。正样本只会在 s=t/a[i] 附近跨过阈值,零样本永远不会通过。

AlphaCoverage.cs 的完整关键实现如下。Reduce2x2 生成原始粗层,Fit 接收参考层的通过数和总数,枚举允许区间内的跳变候选,再用整数交叉乘积比较比例误差。候选误差相同时选择其中更接近 1 的系数;仍并列则沿用较小候选。返回值同时保存实际通过数、目标和残差。

namespace AlphaCoverageLab;

public readonly record struct CoverageFit(
    double Scale, int Hits, int Samples, double Target, double Error);

public static class AlphaCoverage
{
    public static int Count(double[] alpha, double cutoff, double scale = 1)
        => alpha.Count(a => Math.Min(1, a * scale) >= cutoff);

    public static double[] Reduce2x2(double[] alpha, int width, int height)
    {
        if (width <= 0 || height <= 0 || width % 2 != 0 || height % 2 != 0
            || (long)width * height != alpha.Length)
            throw new ArgumentException("Expected an even rectangular image.");
        Validate(alpha);
        var result = new double[alpha.Length / 4];
        for (int y = 0; y < height; y += 2)
        for (int x = 0; x < width; x += 2)
        {
            int i = y * width + x;
            result[(y / 2) * (width / 2) + x / 2] =
                (alpha[i] + alpha[i + 1] + alpha[i + width] + alpha[i + width + 1]) / 4;
        }
        return result;
    }

    public static CoverageFit Fit(double[] mip, int referenceHits,
        int referenceSamples, double cutoff, double maxScale = 4)
    {
        Validate(mip);
        if (referenceSamples <= 0 || referenceHits < 0 || referenceHits > referenceSamples
            || !double.IsFinite(cutoff) || cutoff <= 0 || cutoff > 1
            || !double.IsFinite(maxScale) || maxScale < 1 || maxScale > 16)
            throw new ArgumentException("Invalid coverage contract.");
        var candidates = new SortedSet<double> { 0, 1, maxScale };
        foreach (double a in mip.Where(a => a > 0))
        {
            double scale = cutoff / a;
            if (!double.IsFinite(scale) || scale > maxScale) continue;
            // 保证相等阈值在当前浮点运算中确实通过。
            while (a * scale < cutoff) scale = Math.BitIncrement(scale);
            if (scale <= maxScale) candidates.Add(scale);
        }
        long bestError = long.MaxValue;
        double bestScale = 1;
        int bestHits = 0;
        foreach (double scale in candidates)
        {
            int hits = Count(mip, cutoff, scale);
            long error = Math.Abs((long)hits * referenceSamples - (long)referenceHits * mip.Length);
            if (error > bestError || (error == bestError &&
                Math.Abs(scale - 1) >= Math.Abs(bestScale - 1))) continue;
            (bestError, bestScale, bestHits) = (error, scale, hits);
        }
        double target = (double)referenceHits / referenceSamples;
        return new(bestScale, bestHits, mip.Length, target,
            Math.Abs((double)bestHits / mip.Length - target));
    }

    private static void Validate(double[] alpha)
    {
        if (alpha.Length == 0 || alpha.Any(a => !double.IsFinite(a) || a < 0 || a > 1))
            throw new ArgumentException("Expected finite normalized alpha.");
    }
}

这里固定默认缩放上限为 4,它是这份资产试验的约束,不是通用最佳值。极淡样本若需要更大的系数,接口会留下误差。Count 是当前已校验调用链内部的计数辅助函数;外部输入须经过 FitReduce2x2,不能把它视为独立的完整验证入口。

浮点除法还可能让理论上的临界值在乘回去后略小于阈值,所以候选会向上移动到实际可通过的位置。这个处理只约束当前 double 运算,不承诺转换成纹理字节或 GPU float 后仍在阈值同一侧。

固定输入的覆盖率恢复、缩放阶梯及相等 Alpha 的不可达目标

更尖锐的反例是四块各含一个不透明样本。原图覆盖率 25%,粗层却全是 0.25;阈值改为 0.5 后,缩放小于 2 时一个不留,达到 2 时四个一起通过。目标要求保留一个,但统一缩放无法区分四个相等值。当前策略选择 0%,留下 25 个百分点残差;若项目宁愿叶片偏密,也可以定义有方向的误差代价,但那会是另一份验收合同。

把可达比例测出来,再决定如何接入资产构建

Program.csNaiveCoverageLossRegression 保留全灭反例,StrictComparisonRegression 检查把相等改成严格大于会丢失临界样本,UnreachableTiedCoverage 则要求无法恢复时如实返回误差。AllBinaryTilesAgainstReachableCounts 遍历所有 65,536 种 4×4 二值输入:独立按粗层的取值组列出可达通过数,核对实现确实得到最小计数误差,而不是复写一次候选搜索。

2026-09-17 在 .NET 9 Release 下运行,核心命令与结果如下。LAB_DIR 指向包含上述源码和项目文件的实验目录;耗时为测试区间的 Stopwatch 读数,不包含编译,不是纹理处理性能基准。

$ dotnet build "$LAB_DIR/AlphaCoverageLab.csproj" -c Release
Build succeeded. 0 Warning(s), 0 Error(s).
Exit code: 0; elapsed=12.02s

$ dotnet run --project "$LAB_DIR/AlphaCoverageLab.csproj" -c Release --no-build
passed=20 failed=0 skipped=0 samples=65536 elapsedMs=682.7125
cutoff=0.8 scale=1.0666666666666667 target=0.5 error=0
source=8/16 raw=0/4 corrected=2/4
tied: target=0.25 actual=0 error=0.25 scale=1
Exit code: 0

以上是运行 JSON 的关键字段摘录。测试还覆盖零值、全不透明、空参考覆盖率、缩放上限、排列变化和非法输入。这些证据支持 CPU 离散计数结论,没有测量 Unity、Godot 或 Three.js 的实际屏幕面积。

本实现最多为 N 个跳变候选各扫描 N 个样本,时间复杂度 O(N²),适合小输入取证。大纹理导入可改用排序后累计同值组,或量化直方图,但仍要保留相等值整组跳变、比较符号与残差合同。改变搜索方式不能把不可达的比例变成可达。

资产管线应保留未补偿的 Alpha 层作为继续过滤的来源,对各层独立测量并记录修正,避免把已经放大的结果再次送入下一级平均。材质阈值也要进入构建描述:同一张纹理若被不同阈值的材质共用,某个阈值下的修正不能替其他阈值作证。

对于开头的叶片变稀,当前固定样本已经恢复离散通过比例,但没有恢复叶脉与孔洞的位置。实际采样还会经过双线性、跨层混合、压缩和量化;相同总覆盖率也可能对应完全不同的轮廓。接入时应在最终存储格式和实际采样位置上重新测量,超出残差预算就保留更细层级或更换表现策略。可交付的资产结果因此应包括阈值、逐层系数和实际残差,不能只有一个“覆盖率已保持”的布尔标记。