· 5 分钟阅读 · 1917 字

黑白地砖缩远后变暗:颜色平均必须先回到线性光空间

一张黑白相间的地砖颜色贴图,缩到两格只剩一个像素时,应该保留多少光?如果把黑色编码值 0 与白色编码值 1 直接平均,结果是 0.5。但把这个灰色作为 sRGB 颜色读取,它对应的线性值只有 0.214041,低于原来等面积黑白样本的线性均值 0.5。远处地面因此可能比近处暗,修改灯光强度又会把近处一起抬亮。

这个错误发生在缩小算法之前的数据解释上。sRGB 编码值用于存储颜色,不与线性光量成正比。本文建立独立 C# 颜色处理探针,复现两种运算顺序;它没有运行引擎渲染器,也没有测量某块显卡的自动 Mip 生成行为。检验对象是离线贴图工具可以直接执行的颜色平均合同。

黑白材质与灰色纸卡的概念摄影,非纹理处理实测

一个像素代表两块表面时,先还原光量

设两个不透明、等面积样本的编码颜色分量为 a、b,解码函数为 D,编码函数为 E。缩小后的存储值应为 E((D(a)+D(b))/2)。当输出直接进入线性渲染目标时,保留中间的线性均值即可,无需再编码。平均发生在哪个空间,与结果最终存在哪种格式,是两项独立决定。

探针采用分段传递函数:低编码段除以 12.92,其余部分执行 ((c+0.055)/1.055)^2.4;编码使用对应的线性段和幂函数。用统一的 pow(c,2.2) 替代会改变暗部和分段附近的结果。对于工具链回归,保留实际使用的函数比给所有颜色处理写一个模糊的“Gamma 修正”开关更容易追踪。

关键实现位于 .tmp/daily-labs/2026-10-22-1930/ColorAverage.cs,调用链为 Average → Decode → Encode。每个颜色分量独立处理,示例只列标量,避免把向量包装与核心顺序混在一起。

public static class ColorAverage
{
    public static double Decode(double encoded)
    {
        Validate(encoded);
        return encoded <= 0.04045 ? encoded / 12.92
            : Math.Pow((encoded + 0.055) / 1.055, 2.4);
    }

    public static double Encode(double linear)
    {
        Validate(linear);
        return linear <= 0.0031308 ? 12.92 * linear
            : 1.055 * Math.Pow(linear, 1 / 2.4) - 0.055;
    }

    public static double Average(double a, double b) =>
        Encode((Decode(a) + Decode(b)) / 2);

    private static void Validate(double value)
    {
        if (!double.IsFinite(value) || value < 0 || value > 1)
            throw new ArgumentOutOfRangeException(nameof(value));
    }
}

Validate 把这个实现的输入范围限定在有限的 [0,1]。负数、超出 1 的数值和非有限输入会抛异常,调用者不能把 HDR 亮度直接送进这份工具函数。它也没有把非法输入静默裁成黑白;导入错误应在构建阶段暴露,而不应变成一张看似有效的纹理。高动态范围缓冲需要另一个明确允许范围和输出编码的合同。

错误平均会把高对比细节压暗

黑白样本先解码得到 0 和 1,均值为 0.5,再编码得到 0.7353569830524495。错误路径先求编码均值 0.5,再解码得到 0.21404114048223255,只保留正确线性均值约 42.8%。两条路径的输入完全相同,差别来自非线性函数与平均不可交换。

由探针固定输入与输出绘制的线性光平均、编码平均和灰阶对照

差异随样本对比度变化。编码输入 0.2 与 0.8 的正确输出是 0.599931,0.4 与 0.6 为 0.512410;两组直接平均都为 0.5。相同的 0.5 与 0.5 则仍输出 0.5。因此,一张纯灰测试图无法揭示这个错误,需要保留空间上相邻的明暗样本。技术图中的色块由程序按输出数值绘制,纸面背景只用于统一版式。

回归保留错误路径的确定输出,防止测试仅验证编码解码互为逆运算,却没有覆盖实际发生过的错误顺序。Program.cs 中的检查为:

Check("BlackWhiteAverage",
    Math.Abs(ColorAverage.Average(0, 1) - 0.7353569830524495) < 1e-14);
Check("EncodedAverageFailure",
    Math.Abs(ColorAverage.Decode(0.5) - 0.21404114048223255) < 1e-14);

这两项均通过才说明当前实现既满足正确参考值,也固定了错误操作的后果。测试程序还覆盖黑白端点、两个分段阈值、暗部线性分支、非法输入与重复解码。分段常数经过舍入,在阈值附近存在极小不连续;随机往返检查容差设为 1e-7,不把任意输入的结果承诺成逐位可逆。

当天使用 .NET SDK 9.0.202、Release 配置执行:

dotnet build "$PROBE_PROJECT/ColorLab.csproj" -c Release
dotnet run --project "$PROBE_PROJECT/ColorLab.csproj" -c Release --no-build

构建退出码 0,零警告、零错误;运行退出码 0,通过 23 项、失败 0、跳过 0,程序计时 36.7793 ms。固定种子 20261022 的 10,000 组样本同时检查往返误差、平均交换性、输出位于输入之间和解码单调性,最大往返误差为 1.6653345369377348e-16。耗时仅记录本次执行,不构成工具性能结论。

颜色语义应随资源进入构建流程

离线缩图工具需要知道输入是不是编码颜色。基础色、发光颜色等颜色资源,与粗糙度、金属度、遮罩等数据资源,不能因为都装在 PNG 中就走同一条解码路径。数据纹理中的 0.5 是被业务定义的数值,套用颜色解码会把它改成 0.214041。法线贴图还涉及向量重建和归一化,不能用本例的标量均值宣称已经处理正确。

同样需要防止重复解码:若采样接口已经输出线性颜色,后续再调用 Decode(0.5) 就会多做一次变换。探针进一步固定了两次解码的值 0.037649。资源格式、采样视图和离线处理步骤必须共同声明由哪一步完成转换;“已转成线性”应当是一项可检查的数据属性。

对于 Mip 链,每一级都应从正确解释的上一层获得线性样本,按覆盖权重求和,再按存储需求编码。本例只覆盖两个等权、不透明样本;实际过滤核需要权重和为 1,边界覆盖、压缩误差和量化也会影响结果。若上一层已经错误平均,后面再解码无法恢复被合并掉的明暗分布,修复应从原始层重新生成链。

透明颜色还多一项次序约束:先将 RGB 解码到线性空间,再按 Alpha 预乘和过滤;Alpha 本身不使用这份颜色传递函数。线性化解决光量平均,预乘解决覆盖关系,二者分别约束不同的数据含义。本文没有验证透明边缘,只把它列为接入真实资产流水线时必须补齐的分支。

地砖缩远后是否变暗,最终应由实际引擎中的纹理导入、Mip 内容和采样配置共同验收。这里已经确定的是:对本文声明的等面积不透明颜色样本,直接平均编码值会系统性低估线性均值。把颜色语义与转换责任写入资产构建合同,才能让缩图、缓存与运行时采样保持一致,而无需靠灯光参数补偿一条错误的数据链。