· 5 分钟阅读 · 1866 字

镜像 UV 接缝发黑:切线平均不能跨越手性边界

角色服装左右共用一套花纹时,美术常把一侧 UV 镜像以节省纹理空间。几何表面可以连续,顶点法线也完全一致,法线贴图经过接缝后却像突然换了光照方向。若模型处理工具为了减少顶点数,按“位置相同且法线相同”焊接两侧顶点,再平均三角形切线,这条接缝上的两个贡献恰好可能互相抵消。

镜像布料接缝两侧切线方向翻转的版画概念图

本文用一个独立 C# 探针固定三角形 p0=(0,0,0)、p1=(1,0,0)、p2=(0,1,0) 与单位法线 N=(0,0,1)。标准 UV 为 (0,0)、(1,0)、(0,1);U 镜像改为 (1,0)、(0,0)、(1,1)。标准岛得到切线 (1,0,0) 与手性 +1,U 镜像岛得到 (-1,0,0) 与手性 -1。两者对应不同的切线域,不能因为位置和法线相同就进入同一个累积桶。

镜像改变的是 UV 到表面的映射方向

设三角形边为 e1、e2,对应的 UV 差为 d1、d2。二维行列式

det = d1.x * d2.y - d1.y * d2.x

给出 UV 映射的有向面积。镜像会让 det 变号,但翻转哪根基轴取决于镜像轴:本文的 U 镜像让 T 翻转;补充的 V 镜像输入 (0,1)、(1,1)、(0,0) 保持 T=(1,0,0),同时把 w 改为 -1。探针的核心实现位于 .tmp/daily-labs/2026-10-29-1930/TangentFrame.cs,同时保存单位切线 T 和手性符号 w,并约定 B = cross(N, T) * w:

using System.Numerics;

public readonly record struct TangentFrame(Vector3 Tangent, float Handedness)
{
    public Vector3 Bitangent(Vector3 normal) =>
        Vector3.Cross(normal, Tangent) * Handedness;

    public Vector3 ToWorld(Vector3 normal, Vector3 tangentNormal)
    {
        Vector3 world =
            Tangent * tangentNormal.X +
            Bitangent(normal) * tangentNormal.Y +
            normal * tangentNormal.Z;
        return Vector3.Normalize(world);
    }
}

public static class TangentBuilder
{
public static bool TryBuild(
    Vector3 p0, Vector3 p1, Vector3 p2,
    Vector2 uv0, Vector2 uv1, Vector2 uv2,
    Vector3 normal, out TangentFrame frame)
{
    Vector3 edge1 = p1 - p0;
    Vector3 edge2 = p2 - p0;
    Vector2 delta1 = uv1 - uv0;
    Vector2 delta2 = uv2 - uv0;
    float determinant = delta1.X * delta2.Y - delta1.Y * delta2.X;

    if (MathF.Abs(determinant) <= 1e-8f)
    {
        frame = default;
        return false;
    }

    float reciprocal = 1f / determinant;
    Vector3 rawTangent = reciprocal *
        (delta2.Y * edge1 - delta1.Y * edge2);
    Vector3 rawBitangent = reciprocal *
        (-delta2.X * edge1 + delta1.X * edge2);
    Vector3 tangent = rawTangent - normal * Vector3.Dot(normal, rawTangent);

    if (tangent.LengthSquared() <= 1e-8f)
    {
        frame = default;
        return false;
    }

    tangent = Vector3.Normalize(tangent);
    float handedness =
        Vector3.Dot(Vector3.Cross(normal, tangent), rawBitangent) < 0f
            ? -1f
            : 1f;
    frame = new TangentFrame(tangent, handedness);
    return true;
}
}

这段函数的输入前提是位置、UV 与法线分量均为有限值,且 normal 已归一化;生产接口应在调用边界验证这些条件。1e-8 也是当前 UV 尺度下的绝对阈值,只服务于本探针的固定范围。允许极大或极小 UV 坐标的导入器应按资产尺度制定相对判定,不能把该常数直接当成通用退化标准。

正交化只删除切线在法线方向上的数值残留,不能修复错误的累积域。标准岛与镜像岛的切线先相加会得到零向量,后续归一化没有合法结果;若工具用固定 +X 作为回退,镜像岛便获得了标准岛的切线方向。

标准与镜像 UV 的切线手性以及错误跨岛焊接结果

焊接键必须描述着色输入,而不只是几何位置

探针先把切线空间法线 (1,0,1) 归一化后分别转换到世界空间。标准岛得到 (0.707107,0,0.707107),U 镜像岛的正确结果是 (-0.707107,0,0.707107)。若镜像岛错误复用 +X 切线,结果又回到正 X 方向;对这个固定 X 分量样本,两者相差 90°。这不是浮点精度问题,也不能靠再次归一化消除。

手性符号由另一组输入独立检查。切线空间法线 (0,1,1) 归一化后,镜像框架的 w=-1 重建出世界方向 (0,0.707107,0.707107);只把 w 错写为 +1,结果变成 (0,-0.707107,0.707107),该固定 Y 分量样本同样相差 90°。前一个样本验证错误 T,后一个样本验证错误 w;90°不是所有法线贴图采样的固定误差。

因此,离线导入器或运行时 Mesh 生成器可以共享几何位置,却不能据此共享全部顶点着色数据。UV 坐标不同、UV 岛不同或手性不同,都必须形成切线累积边界。最直接的实现是在最终渲染顶点上累积;若管线先按控制点聚合,则累积键至少要覆盖法线域、UV 域与手性,随后再输出与索引一致的顶点副本。Shader 只负责消费 N、T、w,无法从已经归零或焊错的切线中恢复原来的 UV 映射。

只比较 w 也不充分。两个 UV 岛即使手性同为正,岛的旋转或非均匀拉伸仍可能让同一几何点需要不同切线。可靠的处理单位是“三角形角点属于哪个着色域”,而非“这个空间位置是否见过”。索引缓冲复用的是完整渲染顶点;只要 UV、法线或切线框架有一项需要分裂,就应输出不同索引。镜像接缝本来已经需要两套 UV 坐标,保留两份切线不会额外制造一种边界,只是避免后续优化错误地把边界抹掉。

这也限定了优化顺序:先确定硬边、UV 岛和材质需要的顶点分裂,再在各自域内累积并正交化切线,完成这些步骤后才按完整属性去重。若先按位置压缩,之后再生成切线,丢失的角点归属无法从压缩后的索引恢复。若顶点带宽成为问题,应量化真实接缝带来的副本比例,而不是用跨域焊接换取不可预测的法线贴图错误。

退化 UV 是另一条必须显式失败的路径。三个 UV 共线时 det=0,逆矩阵不存在。探针返回 false,让调用者选择删除退化面、修复 UV 或使用有明确约定的替代基,而不是把无穷大和 NaN 写进 Mesh。替代基只能保证数据可用,不能冒充由纹理参数化推导出的真实切线。

固定输入把接缝错误压缩成可回归合同

当天执行命令为:

dotnet build $WORK_DIR/TangentMirrorLab.csproj -c Release
dotnet run --project $WORK_DIR/TangentMirrorLab.csproj -c Release --no-build

Release 构建退出码为 0,零警告、零错误;运行退出码为 0,21 项检查通过,失败 0、跳过 0,探针内部计时 32.3068 ms。除固定三角形外,程序还用固定种子生成 10,000 组正尺度 UV 与对应 U 镜像:20,000 个独立切线框架全部成功,若按位置把每对切线相加,10,000 组都落入零长度分支。

这个切片验证了 CPU 侧切线构建、手性保存和错误焊接的数值结果,没有接入某个引擎的模型导入器、GPU 顶点格式或特定法线贴图通道约定。接入实际渲染管线时,w 的定义、纹理 Y 轴方向与 Shader 重建公式必须采用同一合同;符号约定可以不同,但 UV 岛边界被错误合并后,任何约定都无法补回已经丢失的方向信息。