透明纹理缩小时的色边:预乘必须发生在过滤之前

一个红色徽章缩成小图标后,边缘泛出紫色。纹理没有采到邻近图块,透明区域的 Alpha 也确实为零;把透明度单独显示出来,轮廓甚至完全正确。要定位这类色边,可以把整张图缩成两个像素:左边是不透明红色,右边是全透明蓝色,再让一次过滤各取一半。蓝色原本不可见,却会在错误的计算顺序里重新出现。

红色徽章缩小后的污染边缘与干净边缘概念示意;精确颜色以数值图为准

这个案例针对同一纹理内部的隐藏底色。图集邻块串色来自过滤范围越界,需要布局、扩张宽度或采样限制;这里即使只有一张独立纹理,也能复现问题。两种故障外观相似,修复位置却不同。

Alpha 为零,为什么蓝色还留下来了

约定 RGB 已经处于线性空间,数值范围为零到一;Alpha 也是零到一的线性权重。输入用非预乘表示 Straight(C, A),两个像素分别是 ((1,0,0),1)((0,0,1),0)。本文的过滤核是等权平均,可以看作一次两样本缩小,不涉及具体引擎的采样器默认设置。

如果分别平均 RGB 和 Alpha,结果是紫色 (0.5,0,0.5) 与透明度 0.5。随后才把 RGB 乘 Alpha,写到黑背景上的贡献就成了 (0.25,0,0.25)。蓝通道的四分之一来自那个原本完全透明的像素;只检查过滤后的 Alpha,无法发现这件事。

我们真正希望平均的是各输入对画面的贡献。令 P = C × A,先把两个像素变成 ((1,0,0),1)((0,0,0),0),再同时过滤 P 和 A,便得到 P=(0.5,0,0)A=0.5。这正是不透明红色覆盖一半区域的结果。差异来自乘法与平均不能任意交换:

错误:mean(C) * mean(A) = (0.25, 0.00, 0.25)
正确:mean(C * A)       = (0.50, 0.00, 0.00)

在这个等权核下,只要透明像素的 RGB 有限且 Alpha 为零,它对 P 的贡献就是零。这个条件给了资源管线一个可执行的验收标准:改变全透明像素的隐藏颜色,不应改变过滤后的可见贡献。

让缩小函数接收明确的颜色表示

本次最小工程 AlphaFilterLab.csproj 使用 C# 和 System.Numerics.Vector3,核心文件为 AlphaFilter.cs。调用链是 Program.cs → Downsample → Encode → Average,结果再交给 OverOpaque 合成到不透明背景。它是纹理处理的 CPU 数值切片,没有接入 Unity 导入器或实际 GPU 渲染。

完整关键实现如下。StraightPremultiplied 拆成两个类型,使调用者能够看出 Average 应收到哪一种数据;这不是完整的资产类型系统,公开构造器仍允许调用者绕过检查。当前链条保证原始像素都先经过 Encode,后续 Mip 则直接平均已有的预乘结果。

using System.Numerics;

public readonly record struct Straight(Vector3 Color, float Alpha);
public readonly record struct Premultiplied(Vector3 Color, float Alpha);

public static class AlphaFilter
{
    public static Premultiplied Encode(Straight pixel)
    {
        Vector3 c = pixel.Color;
        if (!float.IsFinite(c.X) || !float.IsFinite(c.Y) ||
            !float.IsFinite(c.Z) || !float.IsFinite(pixel.Alpha) ||
            c.X < 0 || c.X > 1 || c.Y < 0 || c.Y > 1 ||
            c.Z < 0 || c.Z > 1 || pixel.Alpha < 0 || pixel.Alpha > 1)
            throw new ArgumentOutOfRangeException(nameof(pixel));
        return new(c * pixel.Alpha, pixel.Alpha);
    }

    public static Premultiplied Average(ReadOnlySpan<Premultiplied> pixels)
    {
        if (pixels.IsEmpty) throw new ArgumentException("Empty footprint");
        Vector3 color = Vector3.Zero;
        float alpha = 0;
        foreach (var pixel in pixels)
        {
            color += pixel.Color;
            alpha += pixel.Alpha;
        }
        return new(color / pixels.Length, alpha / pixels.Length);
    }

    public static Premultiplied Downsample(ReadOnlySpan<Straight> pixels)
    {
        var encoded = new Premultiplied[pixels.Length];
        for (int i = 0; i < pixels.Length; i++) encoded[i] = Encode(pixels[i]);
        return Average(encoded);
    }

    public static Vector3 OverOpaque(Premultiplied source, Vector3 background)
        => source.Color + background * (1 - source.Alpha);
}

Encode 拒绝非有限值与范围外输入,避免把无效资产值混进整块过滤结果。这个版本刻意限定低动态范围颜色;若纹理保存 HDR 发光值,RGB 上界就需要另立合同,不能直接套用这段检查。Average 只接收此链产生的有效预乘样本,不重新验证任意外部构造的数据。

第一层 Mip 从原始像素转换,下一层则继续处理 P 与 A。若每生成一级都把已有 P 再乘一次 Alpha,边缘会一层比一层暗;若把 P 除回 C 再交给普通过滤,又会重新引入表示切换。保持同一种表示贯穿 Mip 链,可以避免这两种额外步骤。当前实现用临时数组使转换边界清楚;它没有测量吞吐,不能据此宣称适合大批量纹理导入。

固定像素经过两种过滤顺序后的颜色贡献,以及导入、Mip 与混合之间的数据合同

图中的数值全部在线性空间,面板底色只区分路径,不充当屏幕颜色测量。对不透明白背景 B,预乘合成式 P + B × (1-A) 给出正确结果 (1,0.5,0.5);错误顺序得到 (0.75,0.5,0.75)。更换背景并不会消除蓝色污染,只会改变它与底色叠加后的外观。

错误顺序也要成为回归样本

Program.cs 中的 LatePremultiplyRegression 故意保留“平均后才预乘”的路径,断言它确实产生紫色贡献;RedEdge 同时检查正确 RGB 与 Alpha。这样测试通过表示反例被成功复现,不表示错误路径已经成为可用算法。决定性输入与断言可以压缩为:

Straight red = new(Vector3.UnitX, 1);
Straight hiddenBlue = new(Vector3.UnitZ, 0);
var correct = AlphaFilter.Downsample([red, hiddenBlue]);
var wrong = AlphaFilter.Encode(new((red.Color + hiddenBlue.Color) / 2, 0.5f));
if (correct.Color != new Vector3(0.5f, 0, 0) || correct.Alpha != 0.5f)
    throw new Exception("Correct path changed");
if (wrong.Color != new Vector3(0.25f, 0, 0.25f))
    throw new Exception("Counterexample changed");

2026-09-06 的 Release 构建为零警告、零错误,构建和运行退出码均为 0。令 $LAB_DIR 指向保留的最小工程目录,执行命令与测试汇总为:

cd "$LAB_DIR"
dotnet build AlphaFilterLab.csproj -c Release --no-restore
dotnet run --project AlphaFilterLab.csproj -c Release --no-build
wrong_rgb=0.25,0.00,0.25 correct_rgb=0.50,0.00,0.00 alpha=0.50
passed=12 failed=0 skipped=0 hidden_colors=125
elapsed_ms=41.537
exit_code=0

耗时只表示本次断言运行,不是过滤性能基准。HiddenColorInvariance 将透明像素各通道取 0、0.25、0.5、0.75、1,共 125 种组合,全部保留相同红色贡献。其余检查覆盖全透明、全不透明、部分透明、两级等权 Mip、空输入、非有限 RGB、非法 Alpha,以及重复乘 Alpha 的反例。全透明结果直接为零,无须除以 Alpha,因此也没有零除分支。

两级 Mip 检查使用大小相等的分组;若边缘分块包含不同数量的原始 texel,直接平均各分组均值并不等价于整体平均,需要携带有效权重。这一点限定了当前 Average 的用途:它实现等权核,不替调用者决定任意形状 footprint 的权重。

资产和材质必须一起切换

修复应落在颜色首次进入过滤的位置。资产处理先在线性空间形成 P,Mip 保存并过滤 P 与 A,材质输出沿用 P,最终对不透明目标采用源颜色系数一、目标颜色系数 1-A 的合成关系。若只更换混合系数,纹理内部已经平均进去的蓝色无法被恢复;若资产已预乘而材质仍给源 RGB 乘 Alpha,本例的红色贡献会由 0.50 降为 0.25

因此,资源描述应明确记录颜色空间与 Alpha 表示,材质兼容性检查也应读取这份声明。把二者作为导入产物的合同,比依靠文件后缀或逐材质猜测更可靠。这里没有实现 sRGB 解码、压缩编码或显卡采样,实际接入时仍需确认解码、预乘、过滤、存储编码和运行时采样的顺序。对非线性编码字节直接做本文运算,不在这份数值证明内。

红色徽章的色边由此有了一条明确排查路径:确认污染是否来自本纹理的透明底色,检查预乘是否早于所有相关过滤,再核对材质是否重复乘了 Alpha。该方案处理常规透明颜色的线性过滤;Alpha Test 的阈值覆盖率、发光的加法合成和图集跨块采样仍是各自的问题。只有这三个环节共用同一种颜色表示,“透明像素不可见”才能在纹理缩小之后继续成立。