动态 Mesh 长出新顶点却被剔除:顶点、索引与 Bounds 必须是一份快照

一块程序化地形向右扩展,新三角形的顶点已经上传,画面边缘却会随相机移动突然消失。若只检查顶点缓冲,这个现象很容易被误判为上传延迟或浮点误差。真正参与一次绘制的还有索引范围与包围盒:索引决定 GPU 会读取哪些顶点,Bounds 决定这批几何是否进入绘制。三者来自不同版本时,渲染器看到的就不是一张可以解释的网格。

动态网格与一致包围盒的主视觉

本文用一个固定网格复现这条边界。当前版本 1 是四个顶点、六个索引组成的矩形,X 范围为 [-1, 1];候选版本 2 新增顶点 (2.5, 0, 0) 和一个三角形,因而应同时变成五个顶点、九个索引,并把 Bounds 的最大 X 扩到 2.5

不能把三个字段分三次交给渲染器

动态 Mesh 的生产者可能是地形编辑、轨迹带、破坏系统或工作线程,消费者却只有渲染提交点。这里真正需要保护的状态不是三个可独立更新的数组,而是 MeshSnapshot:版本、顶点、索引与 Bounds 共同描述同一批可见几何。渲染器只读取 Current,候选数据在验证完成前没有可见性。

DynamicMeshSnapshot.cs 中的核心实现如下。校验顺序先阻止旧工作结果,再检查空几何、三角形完整性、索引范围和顶点是否全部落在 Bounds 内;只有全部成立才替换当前快照。

public readonly record struct Vec3(float X, float Y, float Z);

public readonly record struct Bounds(Vec3 Min, Vec3 Max)
{
    public bool Contains(Vec3 point) =>
        point.X >= Min.X && point.X <= Max.X &&
        point.Y >= Min.Y && point.Y <= Max.Y &&
        point.Z >= Min.Z && point.Z <= Max.Z;
}

public sealed record MeshSnapshot(
    int Version,
    Vec3[] Vertices,
    int[] Indices,
    Bounds Bounds);

public enum RejectReason
{
    None,
    EmptyGeometry,
    InvalidTriangleList,
    IndexOutOfRange,
    BoundsExcludeVertex,
    StaleVersion
}

public sealed class DynamicMeshPublisher
{
    public MeshSnapshot Current { get; private set; }

    public DynamicMeshPublisher(MeshSnapshot initial)
    {
        Current = initial;
    }

    public RejectReason TryPublish(MeshSnapshot candidate)
    {
        var reason = Validate(candidate);
        if (reason != RejectReason.None)
            return reason;

        Current = candidate;
        return RejectReason.None;
    }

    private RejectReason Validate(MeshSnapshot candidate)
    {
        if (candidate.Version <= Current.Version)
            return RejectReason.StaleVersion;
        if (candidate.Vertices.Length == 0 || candidate.Indices.Length == 0)
            return RejectReason.EmptyGeometry;
        if (candidate.Indices.Length % 3 != 0)
            return RejectReason.InvalidTriangleList;
        if (candidate.Indices.Any(i => i < 0 || i >= candidate.Vertices.Length))
            return RejectReason.IndexOutOfRange;
        if (candidate.Vertices.Any(v => !candidate.Bounds.Contains(v)))
            return RejectReason.BoundsExcludeVertex;
        return RejectReason.None;
    }
}

这段代码没有尝试修补候选数据。自动扩大 Bounds 看似方便,却会掩盖生产链路漏算;截断越界索引则会静默改变拓扑。提交层应当判定快照是否自洽,几何如何修复仍归生产者所有。

旧 Bounds 不是小误差,而是错误可见性

固定候选的第五个顶点位于 X=2.5。如果仍沿用版本 1 的最大 X=1.0,顶点数据和索引本身都合法,但包围盒不再覆盖实际几何。视锥剔除只会看到旧 Bounds,右侧三角形可能还在视锥内,整批 Mesh 却已被判到视锥外。

探针把这条失败路径写成回归:版本 2 使用旧 Bounds 时必须返回 BoundsExcludeVertex,随后 Current.Version 仍为 1、当前顶点数仍为 4。另一个样本把索引改成 [0, 1, 5];五个顶点的合法下标上限是 4,因此候选返回 IndexOutOfRange,当前六个索引保持不变。失败不是“少画一点”,而是候选完全没有获得发布权。

动态 Mesh 快照校验与失败保持

版本字段还解决异步完成顺序。工作线程较早开始计算的版本 1 如果晚于版本 2 返回,单看数组内容无法判断谁更新;candidate.Version <= Current.Version 让旧结果在接触活动状态前退出。版本必须由拥有网格演进顺序的系统分配,不能由各工作线程按完成时间生成。

一组输出同时证明正常提交和失败保持

当天在 Release 配置执行:

dotnet build $LAB_ROOT/DynamicMeshProbe.csproj -c Release
dotnet run --project $LAB_ROOT/DynamicMeshProbe.csproj -c Release --no-build

构建退出码为 0,0 警告、0 错误,耗时 7.65 秒;测试进程退出码为 0,4 项通过、0 项失败、0 项跳过,测试计时 38.6572 毫秒,进程墙钟时间 2.31 秒。决定性输出是:

accepted: reason=None version=2 vertices=5 indices=9 boundsMaxX=2.5
rejected: reason=BoundsExcludeVertex retainedVersion=2 retainedBoundsMaxX=2.5

第一行证明扩展后的三项数据以版本 2 一起可见;第二行在当前已经发布版本 2 后提交版本 3 的错误 Bounds,候选被拒绝,活动版本与最大 X 都没有回退或被局部覆盖。测试还覆盖不完整索引和旧版本结果,因而结论限定在 CPU 侧提交合同,而不是声称测量了具体引擎的 GPU 性能。

何时需要比线性校验更复杂的方案

当前实现每次发布都扫描索引和顶点,时间复杂度是 O(I + V),适合低频重建或中等规模程序化几何。高频布料、海面或大地形若无法承担整表扫描,应让分块生产者同时产出局部最大索引与局部 Bounds,再在提交点合并这些可验证摘要;不能因此退回到分别写活动顶点、索引和 Bounds。

这份合同也不处理 GPU 缓冲写入失败。接入 Unity、Godot 或自研渲染器时,CPU 快照通过后仍应先完成候选缓冲上传,再把句柄与快照版本一起切换。本文解决的是“哪一组几何有资格进入上传”,没有把驱动完成信号伪装成已经验证。

回到开头的消失三角形:问题并非 Bounds 要不要重算,而是谁有权宣布一批动态几何已经成立。让生产者交付候选快照,让提交层验证版本、拓扑和空间范围,让渲染器只看最后一次完整成功的 Current,错误数据就会停在可诊断的拒绝原因上,而不会变成随相机角度出现的偶发画面。