· 4 分钟阅读 · 1674 字
镜像 UV 接缝的法线贴图:切线符号不能靠叉乘猜回
一件左右对称的护甲共用同一块法线贴图,模型轮廓和几何法线都连续,接缝处却像灯光突然换了方向。若着色阶段总用 cross(N,T) 重建副切线,镜像 UV 一侧丢失的并非精度,而是坐标系的手性:同一个叉乘只能生成一种方向。
下面把问题压缩成两个共面的三角形。二者使用相同位置与法线,右侧只把 U 轴镜像。探针不调用图形 API,它验证 Mesh 导入、切线生成与 Shader 解码之间必须共同遵守的数据合同。

镜像改变的是基底方向,不是表面法线
固定三角形的位置为 (0,0,0)、(1,0,0)、(0,1,0),法线为 (0,0,1)。普通 UV 是 (0,0)、(1,0)、(0,1);镜像样本只把第二个 U 改为 -1。由位置边与 UV 边解线性方程后,普通切线是 (1,0,0),镜像切线是 (-1,0,0)。
副切线不能在顶点中省略得毫无痕迹。这里存储一个符号:sign = sign(dot(cross(N,T), rawBitangent)),运行时再用 B = cross(N,T) * sign。普通三角形得到 +1,镜像三角形得到 -1;两侧恢复出的副切线都为 (0,1,0)。这就是常见四分量切线中第四分量承担的职责。

TangentSpace.cs 的 Build → TangentFrame.Bitangent → Decode 构成完整调用链。实现先从 UV 雅可比求原始切线与副切线,再把切线对法线做 Gram-Schmidt 正交化,最后保存手性符号:
using System.Numerics;
public readonly record struct Vertex(Vector3 Position, Vector2 Uv);
public readonly record struct TangentFrame(Vector3 Normal, Vector3 Tangent, float Sign)
{
public Vector3 Bitangent => Vector3.Cross(Normal, Tangent) * Sign;
}
public static class TangentSpace
{
private const float Epsilon = 1e-6f;
public static TangentFrame Build(Vertex a, Vertex b, Vertex c, Vector3 normal)
{
if (normal.LengthSquared() < Epsilon)
throw new ArgumentException("法线不能为零", nameof(normal));
Vector3 edge1 = b.Position - a.Position;
Vector3 edge2 = c.Position - a.Position;
Vector2 uv1 = b.Uv - a.Uv;
Vector2 uv2 = c.Uv - a.Uv;
float determinant = uv1.X * uv2.Y - uv1.Y * uv2.X;
if (MathF.Abs(determinant) < Epsilon)
throw new ArgumentException("UV 三角形退化");
float inverse = 1f / determinant;
Vector3 rawTangent = (edge1 * uv2.Y - edge2 * uv1.Y) * inverse;
Vector3 rawBitangent = (edge2 * uv1.X - edge1 * uv2.X) * inverse;
Vector3 n = Vector3.Normalize(normal);
Vector3 projected = rawTangent - n * Vector3.Dot(n, rawTangent);
if (projected.LengthSquared() < Epsilon)
throw new ArgumentException("切线与法线平行");
Vector3 tangent = Vector3.Normalize(projected);
float sign = Vector3.Dot(Vector3.Cross(n, tangent), rawBitangent) < 0f ? -1f : 1f;
return new TangentFrame(n, tangent, sign);
}
public static Vector3 Decode(Vector3 tangentNormal, TangentFrame frame) =>
Vector3.Normalize(frame.Tangent * tangentNormal.X +
frame.Bitangent * tangentNormal.Y + frame.Normal * tangentNormal.Z);
public static Vector3 DecodeWithoutSign(Vector3 tangentNormal, TangentFrame frame) =>
Vector3.Normalize(frame.Tangent * tangentNormal.X +
Vector3.Cross(frame.Normal, frame.Tangent) * tangentNormal.Y +
frame.Normal * tangentNormal.Z);
}
如果直接把原始切线单位化,导入数据中的法线与切线只要有微小不垂直,解码后的基底就会夹带法线方向误差。正交化修正这一点;它不能修复退化 UV,所以行列式绝对值小于 1e-6 时立即拒绝。零法线和切线投影退化同样属于资产或上游计算错误,不能随意选一条轴继续生成。
丢掉一个符号会把绿色通道翻面
固定切线空间法线为归一化后的 (0.2,0.8,0.56)。为了让镜像采样表达同一表面扰动,镜像侧的 X 分量随 U 方向取反。保留符号时,两侧解码出的世界法线完全相同,点积为 1.0。若镜像侧仍使用无符号 cross(N,T),世界法线变成 (0.2006,-0.8026,0.5618),与正确结果点积为 -0.2882。这已经不是一条难以观察的舍入缝,而是主要方向分量反转。
Program.Main 同时检查普通与镜像手性、两侧副切线、固定样本连续性、无符号失败结果、正交与单位长度,并验证退化 UV 和零法线确实被拒绝。当天使用 .NET 9 Release 执行:
dotnet build "$LAB_DIR/TangentSpaceLab.csproj" -c Release
dotnet run --project "$LAB_DIR/TangentSpaceLab.csproj" -c Release --no-build
两条命令退出码均为 0,构建为零警告、零错误。决定性输出如下;耗时覆盖整个验证程序,不是渲染性能数据:
passed=10 failed=0 skipped=0 elapsedMs=5.4175
regular.sign=1 mirrored.sign=-1
regular.bitangent=(0,1,0) mirrored.bitangent=(0,1,0)
correctDot=1 brokenDot=-0.2882448
这个符号必须跟随最终顶点
手性是顶点属性合同的一部分。镜像接缝通常需要拆分顶点,因为同一个位置会对应不同 UV 与不同切线符号;索引缓冲若仍强行共享该顶点,只能保存一个 tangent.w,错误会在进入 Shader 前发生。重新计算法线也不会补回它,因为法线没有记录 UV 参数化的方向。
符号也不适合像普通连续属性那样跨越接缝平均。+1 与 -1 的中点是零,既不表示左手基底,也不表示右手基底;即使着色器再对插值结果取正负,三角形内部仍会出现没有几何意义的分界。因此,生成索引时应把 UV 不连续或手性不同的角视为不同顶点,让每个三角形三个角的符号一致。接缝两边可以继续共享位置和几何法线的数值,但不能共享同一个顶点记录。本文固定样本的正负手性正好提供了一条导入期断言:同一索引引用的所有三角形若要求不同符号,必须先拆点再写入缓冲。
本文探针只覆盖单个平面三角形、单位缩放和一种固定法线样本,没有验证具体引擎的导入器、压缩格式或蒙皮后的非均匀变换。接入生产管线时,应在最终拆点之后生成切线,并确认顶点压缩没有抹掉 w 的正负;Shader 端必须使用与导入器相同的叉乘顺序。若法线贴图约定还会翻转绿色通道,那是纹理坐标约定的另一层变换,不能拿它替代每个顶点的手性。
开头那条护甲接缝要消失,最小修复不是再平滑几何法线,而是让 Mesh 数据保留镜像 UV 已经改变的基底方向。一个符号足以重建副切线,但它必须从切线生成一直活到像素着色;中间任何一步把四分量切线降成三分量,最终的叉乘都无法猜回丢失的那一位。