GPU 遮挡结果回来时,对象可能已经不是原来的对象

遮挡物、当前塔体、迟到轮廓与查询槽位构成的渲染场景

塔体在第 120 帧发出遮挡查询,GPU 还没返回结果,场景流送已经在同一逻辑 ID 上重建了塔体。第 123 帧,旧查询报告可见样本为零。若渲染器只用对象 ID 查表,它会把刚出现的塔体隐藏;下一帧新查询又把它显示出来,结果就是一次与相机运动无关的闪烁。

异步遮挡查询不是一个稍晚到达的布尔值。它是一份只对特定对象修订、特定相机状态和有限帧窗口成立的证明。任何一个前提失效,结果都只能丢弃,而不能覆盖当前可见性。

查询票据必须带回它所证明的上下文

研究探针位于 .tmp/daily-labs/2026-08-27-1930/Program.csOcclusionQueryCoordinator.Issue 不只记录查询槽位,还把槽位代次、发出帧、相机修订和 ObjectHandle 一起封装进 QueryTicket。对象重建时,逻辑 ID 仍为 42,但修订从 Tower@1 增加到 Tower@2

readonly record struct ObjectHandle(int Id, int Revision);
readonly record struct QueryTicket(
    int Slot,
    int Generation,
    int Frame,
    int CameraRevision,
    ObjectHandle Object);

public QueryTicket Issue(ObjectHandle handle, int frame, int cameraRevision)
{
    if (!objects.TryGetValue(handle.Id, out var current) || current != handle)
        throw new InvalidOperationException("Object handle is stale.");

    var slot = nextSlot++ % slots.Length;
    var generation = (slots[slot]?.Generation ?? 0) + 1;
    var ticket = new QueryTicket(slot, generation, frame, cameraRevision, handle);
    slots[slot] = new SlotState(generation, ticket);
    return ticket;
}

槽位代次与对象修订解决的是两个不同问题。查询池容量有限,槽位 0 可能在旧结果返回前已经分配给另一条查询;此时即使对象没变,也必须用 Generation 拒绝旧票据。对象修订则处理销毁重建、LOD 实例替换或批次重排:槽位尚未复用,不代表结果仍适用于当前对象。

逻辑 ID 不能承担这两种身份。它适合让玩法层持续指向“这座塔”,却无法证明 GPU 当时测试的是哪一份包围盒、世界变换和绘制实例。

对象修订也不能只在销毁重建时推进。若同一实例的包围盒、世界变换或参与遮挡测试的代理几何发生离散替换,旧查询证明的空间范围已经改变,也应取得新修订。相反,材质颜色、动画时间等不改变查询代理的属性无需推进修订,否则每帧都会让回读失效。修订边界应与遮挡输入快照一致,而不是机械复用整个实体的版本号。

提交是四项匹配,不是一次字典赋值

结果回读入口 OcclusionQueryCoordinator.Complete 依次验证槽位、对象、相机与延迟窗口。只有仍处于同一上下文的样本数才能改变稳定可见性:

public QueryResult Complete(
    QueryTicket ticket,
    int completedFrame,
    int cameraRevision,
    int visibleSamples)
{
    if (ticket.Slot < 0 || ticket.Slot >= slots.Length ||
        slots[ticket.Slot] is not { } state)
        return QueryResult.UnknownSlot;
    if (state.Generation != ticket.Generation || state.Ticket != ticket)
        return QueryResult.StaleSlot;
    if (!objects.TryGetValue(ticket.Object.Id, out var current) ||
        current != ticket.Object)
        return QueryResult.StaleObject;
    if (ticket.CameraRevision != cameraRevision)
        return QueryResult.WrongCamera;
    if (completedFrame - ticket.Frame > MaxLatencyFrames)
        return QueryResult.TooOld;

    var isVisible = visibleSamples > 0;
    visible[ticket.Object.Id] = isVisible;
    slots[ticket.Slot] = null;
    return isVisible
        ? QueryResult.AppliedVisible
        : QueryResult.AppliedOccluded;
}

相机修订不能由位置距离替代。一次传送、视锥参数切换或立体视图眼位变化,都可能让旧深度关系瞬间失效;给这些离散变化增加 CameraRevision,比在回读时重新猜测“移动得是否足够远”更清楚。探针把最长有效延迟固定为 3 帧,第 10 帧的结果到第 14 帧才完成时返回 TooOld

旧对象查询被拒绝、当前对象查询正常提交的状态流

不匹配时保持可见是有意选择。错误地多画一帧会浪费部分片元工作;错误地隐藏一帧会直接制造缺面。本文探针没有测量 GPU 成本,因此不声称这种策略更快,只确认它把不确定性放在视觉正确的一侧。若项目要抑制长时间可见,可在连续若干次同修订的零样本之后再隐藏,但不能用一条已经失效的结果缩短确认过程。

这也说明遮挡滞回与身份校验不能合并。滞回回答“多少次有效零样本后才隐藏”,身份校验回答“这次零样本是否有资格进入计数”。若先累计再检查身份,一条旧结果仍可能把当前对象的计数从一推到二。可靠顺序是先通过票据门禁,再把有效结果交给当前修订自己的可见性历史;对象修订变化时,历史随之重置为保守可见。

固定样本验证了迟到结果不会污染新实例

测试符号 RebuiltObjectRejectsOldResult 先为 Tower@1 发出查询,再注册 Tower@2,最后让旧结果以零样本返回。断言要求结果为 StaleObject,且对象继续可见。ReusedSlotRejectsOldTicketCameraCutRejectsOldResultLatencyWindowRejectsOldResult 分别覆盖槽位复用、相机切换和超出窗口;正常的零样本与非零样本也各有提交测试。

cd "$WORK_DIR/.tmp/daily-labs/2026-08-27-1930"
dotnet restore GpuOcclusionQueryLab.csproj --ignore-failed-sources
dotnet build GpuOcclusionQueryLab.csproj -c Release --no-restore --nologo
dotnet run --project GpuOcclusionQueryLab.csproj -c Release --no-build

# 决定性输出
Build succeeded. 0 Warning(s). 0 Error(s).
old_query object=Tower@1 frame=120 result=StaleObject visible=true
current_query object=Tower@2 frame=123 samples=18 result=AppliedVisible visible=true
tests pass=8 fail=0 skip=0 duration_ms=34.27

恢复、构建与运行退出码均为 0,墙钟耗时分别为 2.10s、6.96s 与 1.76s;这些数字只描述本次执行,不构成性能结论。测试还确认未知槽位被拒绝,旧对象句柄不能发出新查询。

真实引擎还要决定查询命令位于哪个 Render Pass、回读是否跨队列,以及对象包围体更新与查询发出之间的同步点。探针没有模拟这些 GPU 屏障,也没有给出查询池容量公式。它证明的边界更窄:查询结果的提交权属于持有当前对象与相机状态的协调器,而不是回读回调本身。

查询池容量应由允许的最大在途查询数与可接受延迟共同约束。槽位不够时,覆盖仍在途的票据必须推进代次,使旧回读稳定退化为 StaleSlot;不能等待回读线程替当前槽位“猜”哪条命令先结束。若持续出现槽位复用拒绝,说明池容量、发出频率或回读延迟至少有一项超出设计范围,此时应记录计数并调整策略,而不是放宽身份检查。

回到开头的塔体,Tower@1 的零样本只能说明第 120 帧那份实例被遮挡,不能说明第 123 帧的 Tower@2 应该隐藏。把槽位代次、对象修订、相机修订和帧窗口保存在同一票据中,迟到结果就会停在提交点之外;后续接入真实遮挡查询 API 时,底层接口可以变化,这四项上下文不能被压回一个对象 ID。