蒙皮顶点重复位移:矩阵调色板必须回到 Mesh 局部空间
角色整体向右移动十个单位,蒙皮顶点却跑到了世界坐标 43,预期位置只有 23。骨骼层级可以完全正确,顶点权重也可以只有一个明确的 1;只要蒙皮输出已经是世界坐标,绘制阶段又把它当作 Mesh 局部坐标,角色的世界变换就会再作用一次。
这次把问题压到单根骨骼和一个顶点。绑定时,Mesh 原点在世界 X=10,骨骼原点在 X=12,顶点的 Mesh 局部位置是 (3,0,0),因此世界位置为 13。整体移动后,Mesh 原点变成 20,骨骼原点变成 22,骨骼相对 Mesh 没有变化。正确结果应仍是局部 3,最终绘制到世界 23。所有长度使用同一世界单位,旋转样本绕 +Z 轴,矩阵输入均为仿射变换。

顶点经过的三个空间,决定了矩阵顺序
本篇 C# 研究工程使用 System.Numerics.Matrix4x4 和 Vector3.Transform,采用行向量在左的表示:p * A * B 表示先经过 A,再经过 B。这里的调色板指按骨骼索引保存的蒙皮矩阵数组;实现只求其中一个矩阵,不涉及颜色。
绑定时的顶点属于 Mesh 局部空间,要先进入骨骼绑定局部空间,才能跟随当前骨骼运动。记 Mesh 绑定世界矩阵为 M₀,骨骼绑定世界矩阵为 B₀,当前矩阵为 M 和 B,则该骨骼的绑定转换是 I = M₀ * inverse(B₀)。本例 I 为平移 -2:局部顶点 3 被转换成骨骼局部的 1。
再乘当前 B,得到世界位置 23。若消费端要求 Mesh 局部位置,还需乘 inverse(M),将 23 转回 3。因此完整矩阵为 P = I * B * inverse(M);绘制端再乘 M,才得到最终世界位置。省去最后的逆变换,却不改变消费接口,就会得到 23 + 20 = 43。
这也解释了为什么不应只把骨骼绑定世界矩阵求逆就叫作完整的 inverse bind:输入顶点是否已经处于世界空间,决定了 M₀ 能否省略。采用列向量的接口还要相应反转组合顺序,不能原样抄写本篇表达式。

绑定姿态提供了一个更直接的检查。令当前 M=M₀、B=B₀,则 P 消去为单位矩阵,原始 Mesh 局部位置应被恢复。Mesh 与骨骼共同乘上同一个可逆父级世界变换时,这个父级也应在当前 B 与 M 的组合中抵消。共同父级的移动不应被解释成局部蒙皮变形。
绑定转换随资产保存,当前矩阵随姿态重建
关键实现位于 $LAB/Skinning.cs。TryBind 生成每根骨骼相对于这份 Mesh 的绑定转换;它不能仅按骨骼名称跨 Mesh 复用,因为 M₀ 也参与了定义。TryPalette 则接收当前姿态的骨骼世界矩阵和 Mesh 世界矩阵,返回供局部空间消费端使用的矩阵。
using System.Numerics;
public static class Skinning
{
public static bool TryBind(Matrix4x4 meshBindWorld,
Matrix4x4 boneBindWorld, out Matrix4x4 inverseBind)
{
inverseBind = default;
if (!Affine(meshBindWorld) || !Affine(boneBindWorld) ||
!Matrix4x4.Invert(boneBindWorld, out var worldToBone))
return false;
var candidate = meshBindWorld * worldToBone;
if (!Affine(candidate)) return false;
inverseBind = candidate;
return true;
}
public static bool TryPalette(Matrix4x4 inverseBind,
Matrix4x4 boneWorld, Matrix4x4 meshWorld, out Matrix4x4 palette)
{
palette = default;
if (!Affine(inverseBind) || !Affine(boneWorld) ||
!Affine(meshWorld) ||
!Matrix4x4.Invert(meshWorld, out var worldToMesh))
return false;
var candidate = inverseBind * boneWorld * worldToMesh;
if (!Affine(candidate)) return false;
palette = candidate;
return true;
}
private static bool Affine(Matrix4x4 m)
{
// 本接口只接收有限的行向量仿射矩阵。
float[] values = [m.M11, m.M12, m.M13, m.M14,
m.M21, m.M22, m.M23, m.M24,
m.M31, m.M32, m.M33, m.M34,
m.M41, m.M42, m.M43, m.M44];
return values.All(float.IsFinite) && m.M14 == 0 &&
m.M24 == 0 && m.M34 == 0 && m.M44 == 1;
}
}
这两个入口只接受有限、末列为 (0,0,0,1) 的行向量仿射矩阵。无法求逆或组合结果溢出时返回 false,输出保持默认值;调用方必须检查返回值,不能把默认零矩阵当成有效姿态提交。这里未实现跨帧姿态缓存或整组骨骼提交,失败后是否保留上一份有效结果应由动画系统决定。
绑定矩阵属于资产的绑定关系,当前 B 和 M 则必须取自同一次姿态与场景变换采样。公式无法修复“骨骼取新帧、Mesh 取旧帧”的混合输入。实际批量构建时可以每份 Mesh 只求一次当前逆矩阵,再供所有骨骼复用;本例为暴露依赖,把它保留在单矩阵入口中,未测量批量吞吐。
纯平移会放过错误的乘法顺序
平移矩阵之间可以交换顺序,仅凭 3、23、43 还不足以证明组合位置都正确。让当前骨骼先绕 +Z 旋转 90°,再移动到 X=22,Mesh 仍位于 X=20。顶点在骨骼绑定空间中的偏移是 (1,0,0),旋转后成为 (0,1,0);当前世界位置应为 (22,1,0),转回 Mesh 后为 (2,1,0)。
Program.cs 的 ReversedFactorsBreakRotation 故意改用 B * I * inverse(M),结果约为 (0,3,0)。同文件的 MissingMeshInverseDoubleTranslation 保留 43 的错误样本;测试通过表示错误已被成功复现并区分,不表示错误路径可以使用。下面是前者保留的实际回归片段,Bind() 调用 TryBind,T(x) 创建 X 轴平移,Near 使用距离容差 0.0004:
var bone = Matrix4x4.CreateRotationZ(MathF.PI / 2) * T(22);
var bad = Vector3.Transform(Vertex, bone * Bind() * T(-20));
Require(Near(bad, new(0, 3, 0)) && !Near(bad, new(2, 1, 0)));
验证在 2026-09-14 使用 .NET 9 的 Release 配置运行。$LAB 指保存 SkinningLab.csproj、Skinning.cs 与 Program.cs 的研究工程目录:
dotnet build "$LAB/SkinningLab.csproj" -c Release --no-restore
dotnet run --project "$LAB/SkinningLab.csproj" -c Release --no-build
构建与运行退出码均为 0,构建零警告、零错误。已完成依赖恢复后的构建命令耗时 7.613 秒,运行命令耗时 2.084 秒;下面的 elapsedMs 是程序内测试段耗时,包含测试输出,不是蒙皮性能基准。旋转输出中的 -0 是小量负浮点数保留零位小数的显示结果,断言仍检查原始向量。
correctLocal=3 correctWorld=23 omittedInverseWorld=43
rotatedLocal=(2,1,0) wrongOrderLocal=(-0,3,0)
passed=15 failed=0 skipped=0 samples=1242 elapsedMs=58.7137
其中 AnimatedPivotScalarOracle 检查 1,053 个点与角度组合,预期位置直接由二维旋转的正弦、余弦计算,不再次拼出同一份矩阵。CommonParentScaleRotationShear 检查另外 189 个父级组合,覆盖平移、旋转、非均匀缩放和剪切。边界用例还拒绝奇异绑定骨骼、奇异当前 Mesh、非有限值、投影矩阵和组合溢出。
这些测试只证明当前输入范围内的 CPU 位置计算。接近奇异但仍能求逆的矩阵没有条件数保护;任意大坐标、法线和切线变换、多骨权重、GPU 矩阵布局及实际绘制仍需各自验收。
回到世界位置 43 的错误,修复应落在蒙皮输出与绘制输入的空间约定上。若渲染管线明确消费世界坐标,可以输出 I * B,同时取消后续 Mesh 世界变换;若继续走普通局部 Mesh 绘制,就必须完整返回局部空间。两种路线都能成立,但矩阵数组的生产者与消费者必须选择同一条,不能靠角色恰好位于原点来掩盖缺失的转换。