相机停在 HLOD 边界时,单阈值会让层级逐帧翻转
开放世界相机缓慢经过一组远景建筑时,投影误差可能在 10 像素上下反复变化。若选择器写成“误差大于等于 10 就用精细层,否则用粗略层”,同一组建筑会在相邻帧之间不断替换。即使两份网格都已驻留,轮廓和材质差异仍可能产生可见跳动;若精细层还在流送,直接改活动层级甚至会把稳定的粗略代理换成空引用。

这个问题不需要先讨论某个引擎的 LOD API。决定结果的是选择器有没有记住当前层级,以及“想切换”与“目标已经可用”是否被当作两件事。
同一个误差值,在不同当前状态下应有不同含义
固定案例只保留 Coarse 和 Fine 两层。屏幕空间投影误差达到 12 像素时允许从粗略层进入精细层;已经处于精细层后,只有误差降到 8 像素才退出。8 到 12 像素之间不是第三种 LOD,而是一段保持区:选择器沿用当前状态。
这种不对称意味着选择不再是 level = f(error) 的无状态映射,而是 next = f(current, error, readiness)。同样是 10 像素,当前为 Coarse 时继续使用粗略代理,当前为 Fine 时继续使用精细网格。滞回带把相机抖动留在阈值之间,不把它放大成资源和渲染状态切换。
探针中的核心所有者如下,完整逻辑集中在 HlodOwner.Evaluate:
enum LodLevel
{
Coarse,
Fine
}
enum SelectionResult
{
Kept,
SwitchedToFine,
SwitchedToCoarse,
TargetNotReady,
InvalidError
}
sealed class HlodOwner
{
private readonly double _enterFinePixels;
private readonly double _leaveFinePixels;
public LodLevel Current { get; private set; }
public HlodOwner(double enterFinePixels, double leaveFinePixels, LodLevel initial)
{
if (!double.IsFinite(enterFinePixels) || !double.IsFinite(leaveFinePixels) ||
enterFinePixels <= leaveFinePixels || leaveFinePixels < 0)
throw new ArgumentOutOfRangeException(nameof(enterFinePixels));
_enterFinePixels = enterFinePixels;
_leaveFinePixels = leaveFinePixels;
Current = initial;
}
public SelectionResult Evaluate(double projectedErrorPixels, bool targetReady)
{
if (!double.IsFinite(projectedErrorPixels) || projectedErrorPixels < 0)
return SelectionResult.InvalidError;
var target = Current switch
{
LodLevel.Coarse when projectedErrorPixels >= _enterFinePixels => LodLevel.Fine,
LodLevel.Fine when projectedErrorPixels <= _leaveFinePixels => LodLevel.Coarse,
_ => Current
};
if (target == Current)
return SelectionResult.Kept;
if (!targetReady)
return SelectionResult.TargetNotReady;
Current = target;
return target == LodLevel.Fine
? SelectionResult.SwitchedToFine
: SelectionResult.SwitchedToCoarse;
}
}
构造器要求进入阈值严格大于退出阈值。若顺序反了,保持区会消失或变成两个条件同时成立的重叠区,最终行为只能依赖分支顺序。NaN 和负误差同样不能参与选择,否则一次坏的投影结果可能污染长期状态。
资源未就绪不是另一个选择阈值
误差达到 14 像素说明精细层已经值得显示,不说明它已经能够显示。targetReady 因此只在目标与当前层级不同时参与提交:目标缺失就返回 TargetNotReady,Current 保持 Coarse。流送系统可以据此提升请求优先级,但渲染系统仍有一份有效代理可画。
反方向也一样。相机远离后,即便精细层已经可以退出,如果粗略代理尚未恢复或其材质依赖失败,也应暂时保留精细层。这里的目标不是保证最低几何成本,而是保证活动层级始终指向完整可渲染资源。预算系统若必须强制回收,则需要单独定义降级占位物,不能让选择器假装目标已经就绪。
资源就绪检查必须与活动句柄属于同一个所有权边界。若加载线程只写一个布尔值,随后另一个线程替换资源句柄,选择器仍可能读到“就绪为真、句柄已变化”的组合。实际引擎接入时,更稳妥的输入是带资源代次的不可变候选,主线程或渲染线程验证代次后再切换活动引用。
固定序列把边界抖动量化出来
探针输入十个投影误差样本:11.0, 12.2, 10.2, 9.8, 10.1, 9.9, 10.3, 9.7, 7.8, 8.4。相机先越过进入阈值,随后在 10 像素附近往返,最后越过退出阈值。

以 10 像素单阈值独立计算每一帧,层级序列是 F,F,F,C,F,C,F,C,C,C,发生 5 次切换。加入 12/8 像素滞回后,序列变为 C,F,F,F,F,F,F,F,C,C,只在真正越过两条边界时切换 2 次。这里比较的是固定输入下的状态变化次数,不是性能基准,也不能推导 GPU 时间或加载吞吐。
当天重新执行的合同测试覆盖进入、保持、退出、双向资源缺失、非法误差与阈值顺序:
$ dotnet build ./HlodHysteresisLab.csproj -c Release --no-restore
Build succeeded.
0 Warning(s)
0 Error(s)
exit=0 wall=7.01s
$ dotnet run --project ./HlodHysteresisLab.csproj -c Release --no-build
tests pass=8 fail=0 skip=0 duration_ms=42.909
hysteresis=C,F,F,F,F,F,F,F,C,C switches=2
single_threshold=F,F,F,C,F,C,F,C,C,C switches=5
not_ready=TargetNotReady active=Coarse
exit=0 wall=1.79s
阈值应来自画面容差,而不是随手写一个距离
本文用屏幕空间误差,是因为同一个世界距离在不同视场角、分辨率和包围体尺寸下不代表同样的视觉差异。生产系统可以从几何误差、投影尺度和相机参数计算像素误差,再由内容类型选择进入与退出容差。建筑轮廓、植被簇和地形块不必共用一组数字;真正需要统一的是阈值单位和状态转移语义。
滞回也不是所有闪烁的完整答案。两层资源在阴影、光照探针或材质参数上不连续时,减少切换次数只会降低暴露频率;几何渐变、交叉淡化或一致的烘焙合同仍要单独处理。快速传送还可能一次跨过多级 HLOD,此时选择器需要按可用资源选择安全目标,而不是逐级播放过渡。
这个最小切片解决的是相机停在边界附近时的决策不稳定:当前层级由一个有记忆的所有者维护,进入和退出使用两条可解释的像素阈值,目标资源未就绪时不破坏现有画面。只要系统仍是离散 HLOD 且切换成本不可忽略,这个合同就能直接进入架构评审;当项目开始处理多级预算、遮挡反馈和跨分块统一调度时,应扩展候选数据,而不是退回无状态单阈值判断。