颜色纹理生成 Mip:平均值必须在线性光域计算
把黑白棋盘纹理缩到只剩一个像素,文件里的平均数很容易算成 128。这个结果看似公平:一半是 0,一半是 255。但若这些字节表示 sRGB 颜色,数值中点并不代表一半的线性光量。纹理逐渐切换到较小 Mip 时,远处表面就可能比近处更暗;增加纹理分辨率并不能修复已经写错的低层颜色。
这里把问题固定在一张不透明灰度颜色纹理上:四个等面积像素按行排列为 [0,1;1,0],每个值同时作为 R、G、B,Alpha 恒为 1。只使用 2×2 box 平均,不涉及透明边缘、光照变化和色调映射。这样,缩小前后的线性平均值就成为一条能够直接计算的验收标准。

图中的上下两条链表示不同运算顺序,灰阶只用于展示差异;精确数值以下面的代码和技术图为准。
128 保留了字节中点,却没有保留平均光量
记 sRGB 编码分量为 E、对应线性分量为 L。本文采用代码中的分段转换:E 不大于 0.04045 时除以 12.92,否则计算 ((E+0.055)/1.055)^2.4;编码则在 L=0.0031308 处分段。这里的数值范围都是 0 到 1,没有曝光、物理照度单位或 HDR 扩展。
对固定棋盘,先解码的四个值仍是 0、1、1、0,线性平均为 0.5。把它重新编码,得到约 0.735356983,最近的 8 位整数是 188。若先平均编码分量,则得到 0.5;随后解码只得到 0.214041140,相比期望值低约 57.19%。这个百分比描述指定输入的线性数值损失,不是屏幕观感、显示器亮度或任何性能指标。
因此,运行时即使正确解码了错误 Mip,也只会忠实读出 0.214041140;它不知道原来有两个白像素,无法恢复丢失的平均光量。相反,如果输入已经是线性数据却又解码一次,中灰也会被进一步压低。颜色表示必须在资产入口确定,不能靠采样端反复补一次转换。
编码字节是产物,线性层才继续参与归约
本地 C# 研究项目的核心文件为 Mip.cs,调用链是 Program.Main → DecodeSource → DownsampleLinear → StoreSrgb8。下面是完整实现。每个通道独立运行,调用方负责提供颜色语义和宽高;接口不把“数组里恰好是浮点数”当成线性空间的证明。
public static class Mip
{
private static void Validate(double value)
{
if (!double.IsFinite(value) || value < 0 || value > 1)
throw new ArgumentOutOfRangeException(nameof(value));
}
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.0 / 2.4) - 0.055;
}
public static double[] DecodeSource(ReadOnlySpan<double> encoded)
{
var linear = new double[encoded.Length];
for (int i = 0; i < encoded.Length; i++)
linear[i] = Decode(encoded[i]);
return linear;
}
public static double[] DownsampleLinear(
ReadOnlySpan<double> linear, int width, int height)
{
if (width < 2 || height < 2 || width % 2 != 0 ||
height % 2 != 0 || (long)width * height != linear.Length)
throw new ArgumentException("Even dimensions and matching data required.");
foreach (double value in linear) Validate(value);
var output = new double[linear.Length / 4];
for (int y = 0; y < height; y += 2)
for (int x = 0; x < width; x += 2)
{
int i = y * width + x;
output[(y / 2) * (width / 2) + x / 2] =
(linear[i] + linear[i + 1] +
linear[i + width] + linear[i + width + 1]) * 0.25;
}
return output;
}
public static byte[] StoreSrgb8(ReadOnlySpan<double> linear)
{
var bytes = new byte[linear.Length];
for (int i = 0; i < linear.Length; i++)
bytes[i] = (byte)Math.Round(Encode(linear[i]) * 255,
MidpointRounding.AwayFromZero);
return bytes;
}
}
DownsampleLinear 明确只接受有限、归一化的线性值和偶数尺寸。验证完成后才分配并返回新层,不覆盖源数组。空图、长度不匹配、奇数尺寸和非法分量都直接拒绝;1×N 尾部、奇数边缘的面积权重属于另一份过滤合同,不能用截断索引悄悄处理。
两级生成时,第一次 DownsampleLinear 返回的浮点层直接送入第二次;每一级可另行调用 StoreSrgb8 保存文件。这样做仍需保留上一层工作数据,但避免了把输出编码和量化误差重新引入工作链。若资产工具出于内存预算只能从编码文件继续生成,必须显式解码并接受相应误差,不能再宣称等价于对原始线性样本直接归约。

同一批测试分别检查运算顺序和量化误差
运行环境为 .NET 9、C#、CPU 双精度,日期为 2026-09-12。进入研究项目目录后执行下列命令;构建和运行退出码均为 0,Release 构建为 0 警告、0 错误。耗时来自本次测试进程内的秒表,仅记录用例执行,不用于推断纹理生成吞吐。
cd "$PROBE_PROJECT"
dotnet build MipLab.csproj -c Release --no-restore
dotnet run --project MipLab.csproj -c Release --no-build
passed=20 failed=0 skipped=0 samples=4354 elapsed_ms=71.0657
linear_mean=0.500000 encoded_mean=0.735356983 stored_byte=188
wrong_linear=0.214041140 loss_percent=57.191772
direct_linear=0.315648866 quantized_linear=0.315253698 direct_byte=152 quantized_byte=152
Program.Main 中的 EncodedAverageRegression 保留错误顺序的输出,并断言它等于 0.21404114048223255;正确路径则由 BlackWhiteLinearAverage、BlackWhiteEncodedOutput 和 BlackWhiteStoredByte 分别约束。这里 20 项全部通过,表示正确实现与预期错误样本均被验证,不表示错误算法也满足颜色合同。
EncodedRoundTripGrid 覆盖 4097 个编码分量,最大回转误差为 1.67e-16;LinearReductionGrid 用 257 张固定 4×4 图比较两次 2×2 归约与一次直接求均值。两者各像素权重同为 1/16,测试以 1e-12 容差比较。分段点另有独立断言;采用这些舍入常数的编码解码函数不应被宣称在每一个实数上精确互逆。
量化回归 QuantizedChainRegression 使用以下 4×4 源字节,按行读取,先除以 255 再解码:
29 102 175 248
65 138 211 28
101 174 247 64
137 210 27 100
第一层存储字节为 [94, 189, 162, 144]。保留浮点工作层时,最终线性值为 0.315648866;把第一层字节读回、解码再平均,则为 0.315253698。两者最后都舍入为 152,因此只比较最终截图或字节会漏掉这次中间误差。它还不能证明差异一定肉眼可见,却足以否定“逐级量化始终等价于原始线性平均”。
导入合同还需要说明这是不是颜色
我更倾向把颜色语义、传递函数和 Mip 生成方式放进同一份导入描述。对本例的不透明颜色纹理,工作链应是解码一次、线性过滤、按目标格式编码存储;运行时读取也必须与存储表示匹配。如果更换过滤核、压缩器或工作精度,就用固定棋盘和多级样本重新验收,而不是仅确认文件尺寸与层数。
这份实现没有验证 GPU 自动生成 Mip、纹理格式解码行为或 Unity、Godot、Three.js 的实际渲染结果。法线、粗糙度、遮罩等数据纹理也不能因为放在 RGB 通道里,就套用本篇颜色转换。包含透明度时还需处理线性域中的预乘和过滤顺序;超出 0 到 1 的高动态范围输入会被当前接口拒绝。
黑白棋盘缩成单像素后,188 是本例声明的颜色转换、等面积过滤与舍入规则共同决定的存储结果。把这些规则放在资源构建边界,才能让远处 Mip 保留原图的线性平均;至于目标平台最终呈现的颜色,仍需沿上传、采样与显示输出链完成实际回读验证。