· 5 分钟阅读 · 2017 字

透明纹理边缘串色:预乘必须发生在过滤之前

透明图标的红色轮廓旁边,原图明明没有可见蓝色,缩小时却出现紫边。把图集边距加大仍可能无效:污染颜色来自同一张图内部、Alpha 为零的纹素。只要过滤器先混合直通 RGB,原本不可见的蓝色就会进入半透明样本,随后再乘 Alpha 已经无法区分它来自哪一端。

不透明红与透明蓝在过滤后形成干净红边或污染紫边的机制示意

这张图是过滤机制示意,不是引擎截图。这里采用独立 C# 数值工程 AlphaLab,固定两个纹素:左侧为直通 sRGB RGBA (1,0,0,1),右侧为 (0,0,1,0),插值参数 t=0.5。背景为不透明黑色,所有合成计算在线性光域进行。它与图集越界不同:即使采样足迹从未离开本图,错误的颜色表示也足以制造串色。

透明纹素不该贡献蓝色能量

在这个固定输入里,sRGB 的零与一解码后不变。若直接平均 RGB 和 Alpha,会得到 (0.5,0,0.5,0.5);再按直通颜色合成到黑背景,RGB 变成 (0.25,0,0.25)。蓝色已经被算进结果,同时红色也被压低。调整最终混合因子只能改变整个样本的贡献,不能从混色中恢复原来的覆盖关系。

应先把每个纹素的线性颜色乘上自己的 Alpha,再进行过滤。红色端点成为 (1,0,0,1),透明蓝成为 (0,0,0,0);中点于是为预乘 RGBA (0.5,0,0,0.5)。它的 RGB 已经代表覆盖加权的颜色贡献,叠到黑背景直接保留红色 0.5,不再乘一次 0.5。

设颜色为 C、覆盖权重为 a,预乘量为 P=aC。对两点线性过滤,P=(1-t)P0+tP1,a=(1-t)a0+t*a1;合成到同一个不透明线性背景 B 时,O=P+(1-a)B。因此过滤后的合成等于分别合成两个端点后再作同权重过滤。这个等式解释了当前方案为何成立,也为测试提供了区别于逐字段期望值的检查方式。

“预乘”还包含颜色空间的顺序。线性红色 0.5 编码成显示用 sRGB 约为 0.735357;若先在 sRGB 编码值上乘 0.5,再解码,得到的线性红色只有 0.214041。两条路径都没有蓝色,亮度却不同。Alpha 在这里是线性权重,不跟随 RGB 做 sRGB 解码。

过滤核交付的是预乘线性值

源码调用链为 Program.cs → AlphaFilter.Filter → Prepare → Decode,随后由 OverOpaque 叠到背景;文件均位于 $WORK_DIR/.tmp/daily-labs/2026-10-18-1930/。Rgba 只承载四个数,接口名与调用边界承担表示约定:输入是直通 sRGB,过滤输出是预乘线性值,背景是线性且不透明。完整关键核如下。

namespace AlphaLab;

public readonly record struct Rgba(double R, double G, double B, double A);

public static class AlphaFilter
{
    public static double Decode(double c) => c <= 0.04045
        ? c / 12.92 : Math.Pow((c + 0.055) / 1.055, 2.4);
    public static double Encode(double c) => c <= 0.0031308
        ? 12.92 * c : 1.055 * Math.Pow(c, 1 / 2.4) - 0.055;

    private static void Validate(Rgba c)
    {
        foreach (var v in new[] { c.R, c.G, c.B, c.A })
            if (!double.IsFinite(v) || v < 0 || v > 1)
                throw new ArgumentOutOfRangeException(nameof(c));
    }

    public static Rgba Prepare(Rgba straightSrgb)
    {
        Validate(straightSrgb);
        var c = straightSrgb;
        return new(Decode(c.R) * c.A, Decode(c.G) * c.A,
            Decode(c.B) * c.A, c.A);
    }

    public static Rgba Filter(Rgba left, Rgba right, double t)
    {
        if (!double.IsFinite(t) || t < 0 || t > 1)
            throw new ArgumentOutOfRangeException(nameof(t));
        var a = Prepare(left);
        var b = Prepare(right);
        return new(a.R * (1 - t) + b.R * t,
            a.G * (1 - t) + b.G * t,
            a.B * (1 - t) + b.B * t,
            a.A * (1 - t) + b.A * t);
    }

    // 背景为不透明线性光颜色,输出 Alpha 恒为一。
    public static Rgba OverOpaque(Rgba premult, Rgba background)
    {
        Validate(premult);
        Validate(background);
        if (background.A != 1 || premult.R > premult.A ||
            premult.G > premult.A || premult.B > premult.A)
            throw new ArgumentException("Invalid premultiplied color or background");
        return new(premult.R + background.R * (1 - premult.A),
            premult.G + background.G * (1 - premult.A),
            premult.B + background.B * (1 - premult.A), 1);
    }
}

OverOpaque 拒绝 RGB 大于 Alpha 的输入,因为当前工程限定非 HDR、归一化、非负颜色。在这套范围内,预乘贡献不会超过覆盖权重。这个数值检查不能识别所有表示误用,例如某些直通颜色也恰好小于 Alpha;生产接口仍应以不同类型或资产元数据区分表示,不能只凭范围猜测。

这里也刻意没有提供“过滤后除以 Alpha,再乘回来”的路径。全透明样本的预乘 RGB 为零,合成无须除法;除以很小的 Alpha 会放大数值误差。如果下游必须接收直通颜色,应在那个明确边界处理零 Alpha,并避免把转换反复插入每级 Mip。

固定纹素、正确顺序与三种错误结果的数值对照

修复需要贯穿资产和混合状态

2026-10-18 使用 .NET 9 Release 执行,构建退出码 0、零警告零错误。以下运行退出码为 0,测试通过 24、失败 0、跳过 0,断言与两组扫描总耗时 109.6704 ms;该耗时只描述本次数值测试,不是纹理处理或 GPU 性能指标。

dotnet build "$WORK_DIR/.tmp/daily-labs/2026-10-18-1930/AlphaLab.csproj" -c Release
dotnet run --project "$WORK_DIR/.tmp/daily-labs/2026-10-18-1930/AlphaLab.csproj" -c Release --no-build
passed=24 failed=0 skipped=0
mid=(0.5,0,0,0.5)
over_black_linear=(0.5,0,0,1)
display_srgb_red=0.7353569830524495
naive_linear=(0.25,0,0.25)
decode_after_premultiply=0.21404114048223255
double_alpha_red=0.25
sweeps=2 samples_per_sweep=10001

Program.cs 的第一组扫描固定不透明红,遍历 10,001 个插值参数,同时改变透明端点 RGB,要求可见贡献始终为 (1-t,0,0)。第二组使用固定种子的随机端点,在相同线性背景上比较“先过滤再合成”和“先合成再过滤”,逐分量绝对容差为 1e-12。另有全透明、两端点、全不透明、传递函数分段边界,以及 NaN、无穷、越界输入和非法背景回归。

决定性失败样本也保留在断言中:直通过滤后再乘得到红蓝各 0.25;sRGB 上先预乘再解码得到红 0.214041;已预乘样本再次乘 Alpha 得到红 0.25。前两种错在资产或采样前后的表示转换,第三种错在输出与混合阶段之间的合同,不能用同一个“透明边缘修复”开关代替定位。

对于普通 over 合成,预乘输出对应的 RGB 贡献关系是源项直接相加、目标项乘 1-a;若仍把源 RGB 乘源 Alpha,就落入第三个回归。纹理、Mip 生成、采样结果、材质输出与混合状态必须使用同一套约定。尤其是采样后才在片元阶段乘 Alpha,无法撤销采样器已经混入的透明底色;正确的相邻纹素表示要在过滤发生之前准备好。

资产若仍保存 sRGB 纹理,应先在线性光域形成预乘量,再按对应存储编码保存,并确保采样端按同一编码解码;不能把编码值直接乘 Alpha。本文没有运行 GPU,也没有覆盖块压缩、驱动采样精度、HDR 或负权重重建滤波。接入具体引擎时,应使用这组红色与透明蓝固定输入,逐段回读核对,而不是把 CPU 公式通过当成材质已经配置正确。

这项修复适用于覆盖加权颜色的过滤与 over 合成。它不会保留 Alpha Test 的阈值覆盖率,也不会隔离图集邻图,更不能修正透明物体排序。红边恢复干净的原因很具体:零覆盖纹素不再向过滤器提交蓝色贡献;资产处理负责形成这种表示,渲染链负责让它一直保持到合成结束。