程序化 Mesh 已经变大,为什么离开原位置就突然消失
一个由三角形扩成宽六米四边形的程序化 Mesh,在原点附近看起来完全正确;相机稍微转动,它却整块消失。顶点缓冲已经包含 x=-3 到 x=3 的新坐标,索引也画出了两个三角形,问题仍可能出在 CPU 侧:渲染器继续拿旧三角形的 [-0.5, 0.5] 包围盒做视锥剔除。
这类错误难以从最终画面定位,因为 GPU 没有收到一次“画错”的命令。对象是在提交 Draw Call 之前被判定为不可见。动态几何的有效更新单位因此不只是顶点数组,还包括能解释这些顶点的索引与 Bounds。

Bounds 必须来自最终接受的顶点
探针中的 Bounds3.FromVertices 不接收调用方传来的中心和尺寸,而是遍历候选顶点求每个轴的最小值与最大值。对于固定四边形 (-3,-1,0)、(3,-1,0)、(3,2,0)、(-3,2,0),结果必然是 min=(-3,-1,0)、max=(3,2,0)。
readonly record struct Bounds3(Vector3 Min, Vector3 Max)
{
public bool Contains(Vector3 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 static Bounds3 FromVertices(IReadOnlyList<Vector3> vertices)
{
var min = vertices[0];
var max = vertices[0];
for (var index = 1; index < vertices.Count; index++)
{
min = Vector3.Min(min, vertices[index]);
max = Vector3.Max(max, vertices[index]);
}
return new Bounds3(min, max);
}
}
从最终接受的顶点推导 Bounds,有两个直接好处。几何与剔除边界不会分别依赖两套参数;测试也能检查“所有活动顶点均位于活动 Bounds 内”这个不变量。若调用方确实掌握一个更保守的业务边界,可以在重算结果上扩张,但不能缩小到排除真实顶点。
这里选择扫描顶点数组,而不是只扫描索引实际引用的顶点,是一个有意的保守策略。未被索引引用的顶点不会产生像素,把它们纳入 Bounds 可能让边界稍大,却不会错误剔除可见三角形;只扫描引用顶点虽然更紧,但需要再次遍历索引并处理重复引用。对于频繁重建的小型程序化几何,前者更容易验证。若网格缓冲预留了很远的闲置顶点,保守范围会明显扩大,此时应在候选构建时压缩顶点,或按有效索引集合计算边界,而不是让预留容量参与空间判断。
非法拓扑要在上传前停止
重算 Bounds 之前仍需验证拓扑。候选四边形只有四个顶点,有效索引范围是 [0,3];[0,1,4] 若进入图形 API,可能得到错误、未定义读取或平台相关表现。DynamicMesh.TryUpdate 将这些情况变成稳定结果,并且只让通过全部检查的快照成为 Active。
public UpdateResult TryUpdate(int revision, Vector3[] vertices, int[] indices)
{
if (vertices.Length == 0)
return UpdateResult.EmptyVertices;
if (indices.Length == 0 || indices.Length % 3 != 0)
return UpdateResult.InvalidTriangleCount;
if (vertices.Any(vertex => !IsFinite(vertex)))
return UpdateResult.NonFiniteVertex;
if (indices.Any(index => index < 0 || index >= vertices.Length))
return UpdateResult.IndexOutOfRange;
if (revision <= Active.Revision)
return UpdateResult.StaleRevision;
Active = new MeshSnapshot(
revision,
vertices.ToArray(),
indices.ToArray(),
Bounds3.FromVertices(vertices));
return UpdateResult.Applied;
}
有限数检查必须早于 Bounds 计算。一个含 NaN 的坐标会污染 Vector3.Min/Max,使包围盒失去可排序的几何意义。数组复制则隔离调用方后续改写:提交后再把 vertices[0] 改成 (99,99,99),不能暗中改变活动几何而让 Bounds 保持旧值。
校验次序也减少了失败分支的歧义。空顶点无法定义最小值;索引数量不是三的倍数时,候选甚至不能被解释为完整三角形;越界索引则说明拓扑引用了不存在的数据。只有这些结构条件成立后,Bounds 才代表一个可绘制对象。这样日志中的 IndexOutOfRange 指向数据生产阶段,而不是把问题延迟成某个图形后端的绑定错误。

固定输出把消失条件变成回归条件
源码路径为 .tmp/daily-labs/2026-08-23-2000/Program.cs,入口是 DynamicMesh.TryUpdate,对应回归包括 ExpandedMeshRecalculatesBounds、InvalidIndexPreservesActiveMesh 与 BoundsContainEveryVertex。当天执行结果如下:
$ cd $WORK_DIR/.tmp/daily-labs/2026-08-23-2000
$ dotnet build DynamicMeshBoundsLab.csproj -c Release -p:NuGetAudit=false
Build succeeded.
0 Warning(s)
0 Error(s)
exit=0
$ dotnet run --project DynamicMeshBoundsLab.csproj -c Release --no-build --no-restore
normal result=Applied revision=4 vertices=4 triangles=2 bounds_min=(-3.0,-1.0,0.0) bounds_max=(3.0,2.0,0.0) old_contains_right=False new_contains_right=True
failure result=IndexOutOfRange active_revision=4 snapshot_unchanged=True bounds_max=(3.0,2.0,0.0)
tests pass=8 fail=0 skip=0 duration_ms=30.39
exit=0
正常样本证明新 Bounds 包含旧边界之外的 x=3;失败样本证明越界索引不会改变已经可用的修订 4。这里验证的是 CPU 侧数据合同,没有创建真实 GPU 缓冲,也没有测量上传耗时,因此不能据此声称重算包围盒改善性能。
引擎接入点取决于谁负责剔除
在 Unity 中,对运行时 Mesh 改写顶点和索引后,应在同一更新路径设置或重算 mesh.bounds;若使用自定义渲染批次,则批次管理器可能才是 Bounds 的所有者。Godot 的 ArrayMesh、Three.js 的 BufferGeometry 或自研渲染器名字不同,但判断标准一致:谁读取边界决定是否提交绘制,谁就必须只读取与当前顶点、索引相匹配的边界。
每帧都扫描全部顶点的代价是 O(V)。当动态网格很大且只改局部时,可以维护分块 Bounds,再合并为对象级边界;但顶点向内收缩会使增量最值失效,此时需要重扫受影响分块。若对象允许使用保守边界,扩大少量范围通常比漏掉真实几何安全,代价是增加本可剔除的 Draw Call。
边界过小与过大并不对称。过小会产生假阴性:对象明明与视锥相交,却在 CPU 阶段被丢弃,画面直接缺失;过大产生假阳性:对象已经离开视锥,渲染器仍提交它。前者是正确性故障,后者才是容量与效率问题。因此更新路径应先保证 Bounds 包含真实几何,再根据可见对象数、Draw Call 和剔除统计决定是否值得收紧,不能用一个未经证明的固定盒子换取表面上的低计算量。
开头那个突然消失的四边形并不需要修改 Shader。只要索引先通过范围检查、顶点排除非有限值、Bounds 从同一批有效顶点重算,CPU 剔除与 GPU 几何就重新描述同一个对象。当前探针没有覆盖骨骼变形、GPU 顶点位移和多子网格材质边界;这些场景需要更保守的运行时 Bounds 或由动画、计算着色器结果另行提供边界,而不能继续依赖静态导入值。