地形高度查询:双线性采样为何对不上三角形表面
让地形上的脚底贴住可见表面,要求高度查询和绘制网格描述同一块地面。只共享一张高度表还不够:一个格子的四角分别是 0、0、0、4 米,双线性采样在格心返回 1 米;沿低角到高角连接的三角网格,格心却在 2 米。把查询结果直接用作脚底高度,会留下整整一米的几何偏差。
这里故意使用陡峭的单格放大问题。它来自本篇 C# 研究工程的固定输入,不是引擎截图或角色系统的实测事故。局部 XZ 平面上的格子宽、深各一米,Y 向上;u 沿 +X、v 沿 +Z,均为无量纲格内坐标。四个角按 (u,v) 标为 00、10、01、11。下面的主视觉说明在三角面上查询高度这一操作,精确拓扑与数值在后面的技术图中给出。

对角线已经决定了格心在哪里
若索引连接 00 与 11,格心就在这条边的一半,因而高度为 (0+4)/2=2。若改为连接 10 与 01,格心高度变成 (0+0)/2=0。两块网格拥有相同的四个顶点和外围边,内部表面却不相同。对角线不是可以由查询器随意猜测的绘制细节。
双线性函数会让四角在格心各占四分之一,得到 1。它构造的是另一张连续曲面,一般不等于任何一组两片平面三角形。把过滤模式调得更平滑,不能让这个曲面自动变成实际索引所表示的表面。
本实现用 Diagonal.Main 表示 00—11,Diagonal.Anti 表示 10—01。顶点编号依次是 00,10,01,11;验证用的 Main 三角形是 [0,3,1]、[0,2,3],Anti 是 [0,2,1]、[1,2,3],叉积法线均朝 +Y。这份拓扑同时决定查询点属于哪一片平面。
Main 在 v<=u 时使用 00、10、11,权重为 1-u,u-v,v;另一侧使用 00、11、01,权重为 1-v,u,v-u。权重和为 1,且在各自区域非负。对角线上两种表达式得到相同高度,因此等号归哪一侧不会制造高度裂缝。

图中剖面取 u=v=t,折线点直接来自测试输出的 21 个固定样本。青线是 4t,紫线是 max(0,8t-4),金线是 4t²。中点的三个高度可以不依赖图形渲染直接复算;相同端点并没有约束住曲线内部。
查询入口接收高度,也接收拓扑
研究工程的 TerrainQuery.cs 保存以下完整核心实现。调用链是 Program.Main → TerrainQuery.Sample;批量验证入口 Program.Scan 还会调用独立平面参考 Program.PlaneReference。函数只返回局部高度,不负责角色移动、碰撞响应或格子定位。
public enum Diagonal { Main, Anti }
public readonly record struct Heights(double H00, double H10, double H01, double H11);
public static class TerrainQuery
{
public static double Sample(Heights h, double u, double v, Diagonal diagonal)
{
if (!double.IsFinite(u) || !double.IsFinite(v) ||
u < 0 || u > 1 || v < 0 || v > 1 ||
!double.IsFinite(h.H00) || !double.IsFinite(h.H10) ||
!double.IsFinite(h.H01) || !double.IsFinite(h.H11))
throw new ArgumentException("Finite cell heights and unit coordinates required");
double height = diagonal switch
{
Diagonal.Main when v <= u =>
(1 - u) * h.H00 + (u - v) * h.H10 + v * h.H11,
Diagonal.Main =>
(1 - v) * h.H00 + u * h.H11 + (v - u) * h.H01,
Diagonal.Anti when u + v <= 1 =>
(1 - u - v) * h.H00 + u * h.H10 + v * h.H01,
Diagonal.Anti =>
(1 - v) * h.H10 + (u + v - 1) * h.H11 + (1 - u) * h.H01,
_ => throw new ArgumentException("Unknown diagonal")
};
if (!double.IsFinite(height)) throw new ArithmeticException("Height overflow");
return height;
}
}
这里拒绝越界坐标而不自动夹紧,因为夹紧会把“选错格子”掩盖成一次合法边界命中。调用方应先确定格号,再把位置转换成当前格的 u、v。函数允许 0 和 1,便于直接验证共享边;整张地图仍需由寻址层约定边界归属以及最外圈处理,不能把这个单格闭区间接口当成完整地图寻址方案。
实现接收有限高度,运算结果非有限时也拒绝。当前验证使用小整数角高和普通格内坐标,没有为极端浮点范围提供精度保证。它适用于局部单值高度场;悬崖内侧、洞穴和重叠表面无法用一个 Y 值描述。若对象发生旋转,局部竖直也未必是世界竖直,不能把世界 XZ 直接传进此函数。
对运行时地形,我更倾向让网格生成器与查询器共享同一份角高和对角线记录。若某个 LOD 会改变三角形连接,查询端还必须明确跟随可见 LOD,还是坚持稳定的碰撞地形。两种产品选择都能成立,但应公开承认可能存在的视觉与碰撞差异;函数内部更换一种插值,解决不了上游使用不同网格版本的问题。
用三角形平面验算,再决定能否近似
测试没有再抄一份分片权重充当参考。Program.PlaneReference 从三角形索引取三个三维顶点,先用 XZ 投影的有向面积筛选覆盖面,再通过叉积得到平面方程,求竖直线与该平面的高度。四组角高、两种对角线、每轴 41 个位置,共产生 4*2*41*41=13448 次比较,绝对容差为 1e-10 米。这些小整数输入能被参考实现的 Vector3 精确表示;结果不能外推成任意巨大坐标下的误差保证。
main-center、anti-center 与 bilinear-regression 分别守住 2、0、1 的解析结果。错误替代方案仍保留在 Program.Bilinear 中;回归明确检查它比 Main 中点低一米,而不是让测试套件故意失败。其余用例覆盖角点、四条外围边、两种对角线两侧的连续性、共面角高、相邻格共享边,以及非法坐标、高度和枚举值的拒绝。
2026-09-13 在 .NET 9.0.3 下执行 Release 构建与运行,两个命令退出码均为 0,构建零警告、零错误。$PROBE_PROJECT 表示本篇研究工程;下列耗时仅是本批测试用时,不是地形查询性能基准。
cd "$PROBE_PROJECT"
dotnet build TerrainLab.csproj -c Release
dotnet run --project TerrainLab.csproj -c Release --no-build
passed=14 failed=0 skipped=0
samples=13448 elapsed_ms=59.9710
main=2 anti=0 bilinear=1
max_plane_error=1.77636e-15
build_exit=0 run_exit=0
双线性仍可作为有条件的近似。令非共面项 D=h00-h10-h01+h11。在 Main 的 v<=u 半格,三角形高度减双线性高度等于 D*v*(1-u);另一半等于 D*u*(1-v)。Anti 两半的差分别是 -D*u*v 和 -D*(1-u)*(1-v)。在各自三角区域,这些乘积的绝对值均不超过四分之一,且格心取到上界,所以单格最大竖直差为 |D|/4。这是实数模型的推导,不包含存储量化或浮点舍入。
D 为零时四角共面,两种三角划分和双线性在全格一致;测试的 (1,3,4,6) 就属于此类。当前反例 D 为 4,最大差恰为一米。若业务允许近似,应该按每格 D 与可接受高度误差判断,不能单凭网格“看起来足够密”作保证。
回到脚底贴地的需求,本查询恢复了实际三角面上的高度,但并未恢复光照用的平滑法线。跨对角线时高度连续,坡度通常会改变;脚掌朝向、植被法线和车辆悬挂还要明确使用面法线还是插值法线。先让高度与实际拓扑一致,再单独决定这些表现规则,才能避免用一份未经说明的平滑曲面同时承担几何接触和视觉贴合。