镜像 UV 没有裂缝,法线贴图为什么仍会翻面

角色护甲左右两侧共用同一块法线贴图,UV 岛通过镜像节省纹理空间。几何顶点在接缝处完全重合,法线也都指向 +Z,基础色看不出裂缝;一旦加入切线空间法线贴图,掠射光却让左右两侧呈现相反的凹凸方向。继续焊接顶点或提高贴图分辨率都不会改变这个结果,因为翻转发生在 UV 坐标系的方向,而不是几何位置。

镜像 UV 接缝两侧具有相反切线方向的网格主视觉

本文用一个固定三角形隔离这条链路。三点位置为 (0,0,0)(1,0,0)(0,1,0),几何法线为 N=(0,0,1)。普通 UV 使用 (0,0)(1,0)(0,1);镜像样本只把第二个 UV 改成 (-1,0)。位置与法线不变,因此输出差异只能来自 UV 导数建立的切线基。

UV 行列式决定局部坐标系方向

对三角形两条几何边 E1E2 和两条 UV 差分 dUV1dUV2,切线与副切线由同一个二维行列式求解。普通样本的行列式为 +1,得到 T=+XB=+Y;镜像样本的行列式为 -1,得到 T=-XB=+Y。镜像没有改变表面朝向,却反转了切线空间的一根轴。

仅存三维切线不足以恢复这个差别。着色器常用 cross(N,T) 重建副切线:普通样本得到 +Y,镜像样本却得到 -Y,与求解出的真实 B=+Y 相反。顶点切线的第四分量需要保存手性:

handedness = dot(cross(N, T), B) < 0 ? -1 : +1
B = cross(N, T) * tangent.w

镜像样本的 tangent.w=-1 会把错误的 -Y 再翻回 +Y。这不是材质开关,而是 Mesh 顶点数据与 Shader 之间的数据合同;导入器生成四维切线,Shader 却忽略 w,或运行时重建 Mesh 时把切线压成三维,都会重新制造接缝。

普通与镜像 UV 的切线、副切线和手性重建关系

正交化之后再记录手性

探针入口位于 .tmp/daily-labs/2026-09-02-2000/tangent.tscomputeTangent()。它先从 UV 导数求原始 TB,再把 T 对法线做 Gram-Schmidt 正交化;手性比较使用正交化后的 T 与原始 B,最后由 reconstructBitangent()复现 Shader 侧计算。

function computeTangent(triangle: Triangle): Tangent4 {
  const [p0, p1, p2] = triangle.positions;
  const [uv0, uv1, uv2] = triangle.uvs;
  const edge1 = sub3(p1, p0);
  const edge2 = sub3(p2, p0);
  const du1 = { x: uv1.x - uv0.x, y: uv1.y - uv0.y };
  const du2 = { x: uv2.x - uv0.x, y: uv2.y - uv0.y };
  const determinant = du1.x * du2.y - du1.y * du2.x;
  if (!Number.isFinite(determinant) || Math.abs(determinant) <= 1e-8) {
    throw new RangeError("degenerate UV triangle");
  }
  const reciprocal = 1 / determinant;
  const rawTangent = mul3(
    add3(mul3(edge1, du2.y), mul3(edge2, -du1.y)), reciprocal,
  );
  const rawBitangent = mul3(
    add3(mul3(edge2, du1.x), mul3(edge1, -du2.x)), reciprocal,
  );
  const normal = normalize3(triangle.normal);
  const tangent = normalize3(
    add3(rawTangent, mul3(normal, -dot3(normal, rawTangent))),
  );
  const w: 1 | -1 = dot3(cross3(normal, tangent), rawBitangent) < 0 ? -1 : 1;
  return { ...tangent, w };
}

function reconstructBitangent(normal: Vec3, tangent: Tangent4): Vec3 {
  return mul3(cross3(normalize3(normal), tangent), tangent.w);
}

正交化不能留到各个材质自行处理。切线生成器若输出的 T 仍含法线方向分量,不同 Shader 的归一化次序可能产生不同基;将单位化、正交化和手性一起固化在 Mesh 构建阶段,渲染侧只消费同一份四维合同。

退化 UV 不能覆盖有效切线

三个 UV 中有两个相同,行列式会变成 0,切线方程没有唯一解。探针把 abs(determinant) <= 1e-8 视为失败,不用任意备用轴伪装成正确结果。MeshTangentState.rebuild() 先构造完整候选数组,任一三角形退化就返回 rejected,活动切线仍保持上一份镜像样本 {x:-1,y:0,z:0,w:-1}

当天严格编译和固定输入运行结果如下:

$ cd $WORK_DIR && npm --prefix .tmp/daily-labs/2026-09-02-2000 run build
exit=0 real=1.66s

$ cd $WORK_DIR && npm --prefix .tmp/daily-labs/2026-09-02-2000 test
exit=0 real=0.60s passed=8 failed=0 skipped=0
regular tangent=(1,0,0,1) bitangent=(0,1,0)
mirrored tangent=(-1,0,0,-1) bitangent=(0,1,0)
withoutHandedness mirroredBitangentY=-1
degenerate result=rejected active=(-1,0,0,-1)

这组数据没有模拟完整的顶点共享策略。真实资产在 UV 接缝、硬法线边和材质边界处通常还需要拆分顶点,因为同一顶点索引无法同时保存两套不同的 UV 或切线;拆点解决“一个索引对应几份顶点属性”,tangent.w 解决“镜像坐标系怎样重建副切线”,两者不能互相替代。

资产管线还要避免跨手性平均切线。两个几何面即使位置连续,只要一个 UV 三角形的行列式为正、另一个为负,就属于不同的切线空间方向;先把两侧 T 相加再归一化,可能得到接近零的向量,也可能让结果依赖三角形面积和遍历顺序。可靠流程应先按位置、法线、UV、材质边界与手性决定顶点是否可共享,再只在同一组内累积切线,最后正交化并写入 w。因此,美术工具看到的“几何焊接”不能直接等同于渲染顶点索引共享。

排查时也应把 Mesh 与 Shader 分开验收。Mesh 侧可导出接缝两端的 (N,T,w),确认镜像侧 w 符号相反且向量均为有限单位向量;Shader 侧则暂时输出重建后的 B 或切线空间法线,确认乘法发生在 cross(N,T) 之后。若 Mesh 数据正确而画面仍翻转,应继续检查法线贴图通道约定、对象变换对法线与切线的处理,而不是重新生成 UV。若 w 在序列化、压缩或自定义顶点布局中丢失,重新烘焙贴图同样不会修复接口缺口。

开篇的光照翻面最终可以沿固定路径定位:确认镜像三角形的 UV 行列式为负,检查导入或运行时 Mesh 是否保留四维切线,再确认 Shader 使用 cross(N,T) * tangent.w。当前探针不评价具体引擎的切线生成算法,也不覆盖顶点平均、压缩精度和对象到世界空间的非均匀缩放;它建立的是更基础的接口边界:只要法线贴图工作在切线空间,镜像 UV 的方向信息就必须从 Mesh 构建阶段一直传到着色器,不能在三维切线处被截断。