· 4 分钟阅读 · 1657 字
法线压进两通道后翻到错误半球:八面体解码必须撤销折叠
把单位法线压进两个纹理通道后,模型背面的高光可能向侧面偏移,而正面、坐标轴方向和大多数测试球看起来都正常。问题不一定来自量化精度。八面体编码为了让整个球面填满一个方形,会把 z<0 的下半球折到方形四角;解码端若把折叠后的 (x,y,1-|x|-|y|) 直接单位化,得到的仍是有限单位向量,却不是原方向。

固定输入 n=(0.199007,-0.398015,-0.895533) 位于下半球。编码结果是 (0.733333,-0.866667),它落在方形内、赤道菱形外。完整解码返回原法线,当前 float 运算下角误差为 0°;遗漏展开时得到的方向角误差为 36.800484°。这一错误不会产生 NaN,也不会越过 [-1,1] 的纹理范围,因此仅检查格式、长度或采样是否成功都捕获不到它。
方形不是球面的平面投影
编码先把单位法线除以 L1 长度:
p = n / (|nx| + |ny| + |nz|)
此时上半球落入 |px|+|py|≤1 的菱形。下半球还需要以符号保留象限,把 x、y 变换为 (1-|y|)sign(x) 与 (1-|x|)sign(y),从而填满方形剩余四角。这个映射是折叠,不是删除 z 后的普通正交投影。解码时由 z=1-|x|-|y| 判断采样点是否在菱形外;若 z<0,必须执行同一组交换绝对值的展开,再做单位化。
探针核心实现位于 .tmp/daily-labs/2026-10-31-1930/OctahedralNormal.cs。Encode 和 Decode 共用 SignNotZero,使坐标轴与负极点拥有确定表示;DecodeWithoutUnfold 只保留为失败回归:
using System.Numerics;
public static class OctahedralNormal
{
public static Vector2 Encode(Vector3 normal)
{
ValidateNormal(normal);
normal = Vector3.Normalize(normal);
float inverseL1 = 1f / (MathF.Abs(normal.X) + MathF.Abs(normal.Y) + MathF.Abs(normal.Z));
Vector3 projected = normal * inverseL1;
if (projected.Z < 0f)
{
float x = projected.X;
float y = projected.Y;
projected.X = (1f - MathF.Abs(y)) * SignNotZero(x);
projected.Y = (1f - MathF.Abs(x)) * SignNotZero(y);
}
return new Vector2(projected.X, projected.Y);
}
public static Vector3 Decode(Vector2 encoded)
{
ValidateEncoded(encoded);
Vector3 normal = new(encoded.X, encoded.Y, 1f - MathF.Abs(encoded.X) - MathF.Abs(encoded.Y));
if (normal.Z < 0f)
{
float x = normal.X;
float y = normal.Y;
normal.X = (1f - MathF.Abs(y)) * SignNotZero(x);
normal.Y = (1f - MathF.Abs(x)) * SignNotZero(y);
}
return Vector3.Normalize(normal);
}
public static Vector3 DecodeWithoutUnfold(Vector2 encoded)
{
ValidateEncoded(encoded);
Vector3 normal = new(encoded.X, encoded.Y, 1f - MathF.Abs(encoded.X) - MathF.Abs(encoded.Y));
return Vector3.Normalize(normal);
}
public static (byte X, byte Y) QuantizeUnorm8(Vector2 encoded)
{
ValidateEncoded(encoded);
return (Quantize(encoded.X), Quantize(encoded.Y));
}
public static Vector2 DequantizeUnorm8(byte x, byte y) =>
new(x / 255f * 2f - 1f, y / 255f * 2f - 1f);
private static byte Quantize(float value) =>
(byte)Math.Clamp((int)MathF.Round((value * 0.5f + 0.5f) * 255f), 0, 255);
private static float SignNotZero(float value) => value < 0f ? -1f : 1f;
private static void ValidateNormal(Vector3 normal)
{
if (!float.IsFinite(normal.X) || !float.IsFinite(normal.Y) || !float.IsFinite(normal.Z) ||
normal.LengthSquared() <= 1e-20f)
throw new ArgumentOutOfRangeException(nameof(normal));
}
private static void ValidateEncoded(Vector2 encoded)
{
if (!float.IsFinite(encoded.X) || !float.IsFinite(encoded.Y) ||
MathF.Abs(encoded.X) > 1f || MathF.Abs(encoded.Y) > 1f)
throw new ArgumentOutOfRangeException(nameof(encoded));
}
}
零向量没有方向,非有限法线无法建立 L1 投影,超出方形的编码也不属于这个数据合同;探针在提交边界直接拒绝这些输入。极小但非零的向量会先单位化,实际资产管线若可能产生接近零的切线空间法线,应在进入编码器前定义退化策略,不能让压缩函数猜测应替换成表面法线还是 (0,0,1)。

量化误差不能掩盖拓扑错误
探针用固定种子 193031 在球面均匀生成 10,000 个方向。未量化的编码往返最大角误差为 0.0279765°,这是 float 点积接近 1 时反余弦放大的数值残差。随后把两个编码分量从 [-1,1] 映射到 UNorm8,按最近整数存储,再解码;最大角误差为 0.9217481°,平均为 0.3349909°。这些数字只描述当前两通道各 8 位、当前舍入方式和固定样本,不外推到其他位宽或编码格式。
同一批样本中,4,687 个方向满足 z<-0.05。遗漏展开的最大角误差达到 54.22049°。增加通道位宽只能缩小格点量化误差,无法修复球面折叠的逆映射;用 RG16 替换 RG8 后画面仍然偏转,正是这两类误差被混为一谈的典型信号。
边界也需要单独覆盖。北极编码到方形中心,南极可以落到任一等价角点;当前 SignNotZero(0)=1 让它稳定选择 (1,1)。赤道对应菱形边界,z=0 时无需展开。编码值在量化后可能跨过这条边界,但正确解码在边界两侧连续,误差由格点间距决定。若 CPU 烘焙与 Shader 解码对 sign(0)、UNorm 映射或舍入方式采用不同约定,轴向样本会比随机样本更早暴露不一致。
纹理格式还决定 Shader 入口的数值域。UNorm 采样器返回 [0,1],必须先执行 encoded=sample*2-1;SNORM 采样器已经返回 [-1,1],再次重映射反而会把合法数据推到方形外。压缩资产的元数据不能只写“两通道法线”,还要把格式语义交给材质变体或统一解码函数。当前探针在 CPU 侧显式执行一次 UNorm 映射,正是为了让位宽误差与数值域错误分开归因。
验证命令:
dotnet build $WORK_DIR/OctahedralNormalLab.csproj -c Release
dotnet run --project $WORK_DIR/OctahedralNormalLab.csproj -c Release --no-build
Release 构建退出码 0,零警告、零错误,构建工具报告 6.90 s;运行退出码 0,18 项检查通过、失败 0、跳过 0,探针内部耗时 55.7237 ms。检查覆盖上下极点、坐标轴、赤道、固定下半球失败样本、非法输入、浮点往返与 10,000 组 8 位量化。耗时用于标识本次证据,不构成编码吞吐或 GPU 成本结论。
这个切片验证的是单位法线的两通道八面体表示。它没有验证切线框架构造、法线贴图颜色空间、插值后的重归一化,也没有比较 BC5 等块压缩产生的误差。工程接口至少应把“八面体折叠方式、通道有无符号、位宽与解码版本”作为同一资产合同;只记录“RG 法线”不足以阻止烘焙器和 Shader 使用两套逆映射。出现整片背面稳定偏光时,应先验证下半球展开,再讨论是否值得增加位宽。