动态 Mesh 合并顶点时,位置相同不代表能共用索引

两个三角形拼成一块方形面板,四个空间位置,六个三角形角点。为了缩小动态 Mesh,把位置相同的角点合并,看起来应当得到四个顶点。但只要面板两侧采用不同的 UV,四个顶点就可能不够:索引重用的不只是一份坐标,还包括同一编号下的法线与纹理坐标。轮廓完全正确的网格,也能在合并时悄悄改变纹理的插值结果。

同一几何位置对应不同纹理属性记录的面板示意

图中用分离的表面与标记表达属性接缝,不表示几何必须留缝。下面把问题收敛为 C# 网格构建器:输入已经展开成三角形角点,输出一份顶点数组和一份索引数组。它验证 CPU 数据转换,不包含引擎上传或 GPU 截图;因此可以精确回答哪些属性被改写,而不把画面猜测当成测试结果。

四个空间位置,五条顶点记录

面板位于 XY 平面,所有位置的 Z 为零,法线统一为 (0,0,1)。第一个三角形使用 A、B、C,第二个使用 B、D、C,两者绕序一致。B 在前一面上的 UV 是 (1,0),在后一面上是 (0,0);C 的属性则完全相同,可以共享。

如果字典只以位置为键,第二次遇到 B 时会拿到第一次的索引。代码既没有越界,也没有生成破面,却把后一面的 B 改成了前一面的纹理坐标。对于第二个三角形,在三个角点等权的重心处,正确 U 为 (0+1+0)/3,错误 U 为 (1+1+0)/3。这不是缝边的孤立误差:角点属性变化会进入整个三角形的插值。这里计算的是仿射重心样本,不推定任意透视画面的像素位置。

固定六个角点、两种合并键的索引与重心 UV 对照

完整键得到索引 0,1,2,3,4,2,顶点数为五。B 的两条记录拥有相同位置,仍然在空间上重合;C 只保留一条记录。若把 B 的 UV 改成连续值,顶点数便回到四;若两面的共享位置同时存在法线差异,则需要六条记录。这些数量由属性相等关系决定,不能从位置去重数直接推断。

合并键就是当前顶点格式的合同

实际实现位于 $LAB_ROOT/VertexIndexer.cs,入口为 VertexIndexer.Build,核心类型为 VertexIndexedMesh。本切片的顶点格式仅含 Position、Normal、UV,因此这三个字段共同构成字典键。下面是完整文件。

using System;
using System.Collections.Generic;
using System.Numerics;

public readonly record struct Vertex(Vector3 Position, Vector3 Normal, Vector2 UV);
public sealed record IndexedMesh(Vertex[] Vertices, int[] Indices);

public static class VertexIndexer
{
    public static IndexedMesh Build(ReadOnlySpan<Vertex> corners)
    {
        if (corners.Length % 3 != 0)
            throw new ArgumentException("Triangle corners must be a multiple of three.");
        foreach (var v in corners)
            if (!Finite(v.Position) || !Finite(v.Normal) ||
                !float.IsFinite(v.UV.X) || !float.IsFinite(v.UV.Y))
                throw new ArgumentException("Vertex attributes must be finite.");

        var lookup = new Dictionary<Vertex, int>();
        var vertices = new List<Vertex>();
        var indices = new int[corners.Length];
        for (int i = 0; i < corners.Length; i++)
        {
            var vertex = corners[i];
            if (!lookup.TryGetValue(vertex, out int index))
            {
                index = vertices.Count;
                lookup.Add(vertex, index);
                vertices.Add(vertex);
            }
            indices[i] = index;
        }
        return new IndexedMesh(vertices.ToArray(), indices);
    }

    private static bool Finite(Vector3 v) =>
        float.IsFinite(v.X) && float.IsFinite(v.Y) && float.IsFinite(v.Z);
}

构建器先检查三角形角点数量与全部属性的有限性,之后才创建输出候选。它没有持有活动 Mesh,也不会逐步改写调用方的顶点缓冲;输入在末尾包含 NaN 时,调用方拿不到半份结果。返回的数组是新数组,但并非深层不可变对象;如果要跨线程发布,调用方仍需保证交接后不再写入。

记录相等使用当前 C# 数值类型的精确相等语义,不做距离容差合并,也不承诺逐字节相等,正零与负零可以合并。这样安排适合已由同一数据路径生成的角点。对于本来应该相同、却因独立浮点计算略有差异的顶点,当前实现会保留重复项:增加存储是可见代价,静默改变属性却不是可接受的优化。

索引按输入中第一次出现的完整记录编号,输出不依赖字典枚举。相同输入重复构建得到相同数组;调换三角形顺序则可能重新编号。这项确定性足以支持固定输入回归,不能被误解为任意排列下都生成同一个内容哈希。对于 n 个角点,常规哈希假设下时间期望为 O(n),额外空间为 O(n);本文没有测量 GPU 顶点缓存命中率或实际帧时间。

让错误焊接留下可检查的反例

$LAB_ROOT/Program.cs 中的 PositionOnly 保留错误位置键,用于证明测试确实能观察到属性丢失。RoundTrip 则沿输出索引逐角点取回完整属性,与原输入比较。只断言“五个顶点”不够,同样数量的错误映射也可能通过;回读相等才约束了合并的语义。

固定输入在 Sample 中写为:

private static Vertex V(float x, float y, float u, float v) =>
    new(new(x, y, 0), Vector3.UnitZ, new(u, v));
private static Vertex[] Sample() =>
[V(0, 0, 0, 0), V(1, 0, 1, 0), V(0, 1, 0, 1),
 V(1, 0, 0, 0), V(1, 1, 1, 1), V(0, 1, 0, 1)];

对应失败回归与属性回读包含以下关键断言,变量 inputcorrectwrong 分别来自 Sample()Build(input)PositionOnly(input)

Check("position_only_regression", () => wrong.Vertices.Length == 4 &&
    wrong.Vertices[wrong.Indices[3]].UV == new Vector2(1, 0) &&
    input[3].UV == Vector2.Zero);

private static bool RoundTrip(Vertex[] input)
{
    var mesh = VertexIndexer.Build(input);
    return mesh.Indices.Length == input.Length && input.Select((v, i) =>
        mesh.Vertices[mesh.Indices[i]] == v).All(x => x);
}

2026-09-07 在 .NET 9 Release 下执行下列命令;$LAB_ROOT 表示本篇研究工程目录。构建退出码为 0,零警告、零错误,构建器报告耗时 7.64 秒。测试进程退出码为 0,通过 12、失败 0、跳过 0。下列耗时属于本次测试检查,不是运行时性能基准。

cd "$LAB_ROOT"
dotnet build MeshLab.csproj -c Release
dotnet run --project MeshLab.csproj -c Release --no-build
full_vertices=5 position_only_vertices=4
full_indices=0,1,2,3,4,2
corner3_expected_uv=0,0 position_only_uv=1,0
triangle2_centroid_u_correct=0.333333 wrong=0.666667
passed=12 failed=0 skipped=0 cases=289 elapsed_ms=66.132

测试覆盖 UV 接缝、连续 UV、法线不连续、重复三角形、非法角点数量、NaN UV、无限位置、空输入、重复构建与正负零。289 组检查令第二个三角形的 B 点 UV 两分量遍历 -11、步长 1/8,逐组验证属性回读和预期顶点数。法线测试人为改变一面的输入法线,只检验不同法线不会合并,不替代真实折面法线生成测试。构建器也不检查零面积三角形、单位法线或绕序合法性,这些属于上游几何校验。

顶点变少之前,先确定哪些信息允许共享

这里我会把完整属性回读放在顶点合并优化的验收条件中,再讨论减少了多少顶点。碰撞网格或纯位置处理可能允许另一套共享规则;渲染网格却不能拿碰撞拓扑的四个点直接覆盖五条属性记录。两者可以源自同一份几何,但需要分别保留索引映射。

当前键也不是所有 Mesh 的通用答案。增加切线、顶点色、第二套 UV 或蒙皮权重时,必须把参与顶点身份的字段加入比较;若 Shader 根据顶点编号读取外部数据,还要同步重映射那份数据,或禁止这次合并。按 draw 绑定的材质并不天然要求写入顶点键,只有材质选择被编码成逐顶点属性时,才进入这个格式合同。

面板最终保留五条记录,代价是多存一份 B,收益是后一面继续使用自己的 UV。需要容差焊接时,应另行定义量化网格、误差上界与接缝保护规则,再做回归;单纯扩大位置容差无法恢复已被丢弃的属性。动态 Mesh 的压缩入口因此应接收明确的顶点格式,并以“索引展开后属性不变”为验收标准,空间位置数量只是一项统计。