GPU 遮挡回读:相机代际必须阻断旧可见性结果

开放世界渲染器常把遮挡查询结果延迟数帧回读,以免 CPU 等待 GPU。问题在相机瞬移、传送门切换或对象移动时出现:旧视点下“不可见”的结果若直接写回,会把新视点中本应出现的建筑隐藏。证据标识 2026-08-04-2000 的最小 C# 探针把一次查询定义为带相机代际和签发帧的提交票据。

// .tmp/daily-labs/2026-08-04-2000/OcclusionVisibilityGate.cs
// QueryTicket / OcclusionVisibilityGate fields
public readonly record struct QueryTicket(
    int CameraEpoch,
    int IssuedFrame,
    string ObjectId
);

public sealed class OcclusionVisibilityGate
{
    private int cameraEpoch = 1;
    private readonly Dictionary<string, int> visibleSince = [];
    private readonly Dictionary<string, QueryTicket> pending = [];
    private readonly Dictionary<string, bool> visible = [];

    public int CameraEpoch => cameraEpoch;
}

所有权边界

OcclusionVisibilityGate 唯一拥有 CPU 侧可见性、待回读票据与相机代际。GPU 只产生样本是否通过的事实,渲染提交阶段只消费门控后的布尔状态;深度金字塔构建、查询批次调度和 Draw Call 生成均在边界外。核心不变量有两个:旧相机代际不能修改当前可见性;对象最近一次被强制显示之后签发的查询,才有资格再次隐藏它。查询未决或失效时采取保守可见,而不是猜测遮挡。

当前视点与延迟遮挡结果之间的提交边界

查询签发

签发时保存当前 cameraEpoch、帧号与对象标识,并用对象标识替换同对象的旧票据。正常时序是帧 100 签发、后续帧回读、票据仍为当前且对象没有更晚的显式可见事件,结果才能提交。visibleSince 不是性能计数器,而是拒绝移动前查询的因果边界。

// .tmp/daily-labs/2026-08-04-2000/OcclusionVisibilityGate.cs
// Issue
public QueryTicket Issue(string objectId, int frame)
{
    var ticket = new QueryTicket(cameraEpoch, frame, objectId);
    pending[objectId] = ticket;
    visible.TryAdd(objectId, true);
    visibleSince.TryAdd(objectId, frame);
    return ticket;
}

代际失效

相机切换会增加代际、清除全部未决查询,并把已知对象恢复为可见。对象移动也通过 ForceVisible 建立新的可见帧,同时移除旧票据。两者都不试图取消已经进入 GPU 的工作;取消不能保证回读不抵达,真正的安全边界是结果提交时重新验证所有权。

// .tmp/daily-labs/2026-08-04-2000/OcclusionVisibilityGate.cs
// CameraCut / ForceVisible
public void CameraCut(int frame)
{
    cameraEpoch++;
    pending.Clear();
    foreach (var objectId in visible.Keys.ToArray())
    {
        visible[objectId] = true;
        visibleSince[objectId] = frame;
    }
}

public void ForceVisible(string objectId, int frame)
{
    visible[objectId] = true;
    visibleSince[objectId] = frame;
    pending.Remove(objectId);
}

提交谓词

Resolve 同时检查同代际、当前票据和帧序。三项全部成立时,samplesPassed=false 才能把对象转为遮挡。只比较对象标识看似足够,却无法区分相机切换前后的同一建筑;只清空队列也不足,因为已发出的 GPU 工作仍可能晚到。提交谓词把不可取消的异步执行与可控的状态发布分开。

// .tmp/daily-labs/2026-08-04-2000/OcclusionVisibilityGate.cs
// Resolve / IsVisible
public bool Resolve(QueryTicket ticket, bool samplesPassed)
{
    if (ticket.CameraEpoch != cameraEpoch ||
        !pending.TryGetValue(ticket.ObjectId, out var current) ||
        current != ticket ||
        ticket.IssuedFrame < visibleSince[ticket.ObjectId])
        return false;

    visible[ticket.ObjectId] = samplesPassed;
    pending.Remove(ticket.ObjectId);
    return true;
}

public bool IsVisible(string objectId) =>
    visible.GetValueOrDefault(objectId, true);

查询签发、相机切换与旧回读拒绝的固定时序

失败回归

失败时序固定在帧 240 签发查询,帧 241 切换相机,帧 243 收到旧结果。正确状态是拒绝提交并保持 Tower_A 可见。另一条回归让对象在查询后移动,证明即使相机代际未变,移动前结果也不能覆盖更晚的显式可见事件。当前查询正常隐藏、相机切换拒绝和对象移动拒绝三条测试共同锁定提交合同。

// .tmp/daily-labs/2026-08-04-2000/Program.cs
// camera cut rejects stale result / movement rejects pre-move result
Test("camera cut rejects stale result", () =>
{
    var gate = new OcclusionVisibilityGate();
    var query = gate.Issue("Tower_A", 100);
    gate.CameraCut(101);
    Equal(false, gate.Resolve(query, samplesPassed: false));
    Equal(true, gate.IsVisible("Tower_A"));
});

Test("movement rejects pre-move result", () =>
{
    var gate = new OcclusionVisibilityGate();
    var query = gate.Issue("Tower_A", 100);
    gate.ForceVisible("Tower_A", 102);
    Equal(false, gate.Resolve(query, samplesPassed: false));
    Equal(true, gate.IsVisible("Tower_A"));
});

运行证据

探针使用 .NET 9 标准库且没有外部包。Release 构建为 0 警告、0 错误;三个用例通过,失败 0、跳过 0,测试逻辑耗时 145.301 毫秒,完整进程耗时 2.71 秒,退出码 0。耗时只证明本次证据已执行,不支持 GPU 性能或帧耗结论。

$ cd "$WORK_DIR"
$ dotnet restore \
  .tmp/daily-labs/2026-08-04-2000/OcclusionVisibilityProbe.csproj
Restored OcclusionVisibilityProbe.csproj (in 162 ms).
$ dotnet build \
  .tmp/daily-labs/2026-08-04-2000/OcclusionVisibilityProbe.csproj \
  -c Release --no-restore
Build succeeded.
    0 Warning(s)
    0 Error(s)
$ /usr/bin/time -p dotnet run \
  --project .tmp/daily-labs/2026-08-04-2000/OcclusionVisibilityProbe.csproj \
  -c Release --no-build --no-restore
PASS current query may hide object
PASS camera cut rejects stale result
PASS movement rejects pre-move result
tests=3 passed=3 failed=0 skipped=0 duration_ms=145.301
real 2.71
user 1.25
sys 0.57
exit_code=0
{
  "evidenceId": "2026-08-04-2000",
  "frame": 243,
  "cameraEpoch": 2,
  "visible": {
    "Tower_A": true
  },
  "pending": []
}
staleAccepted=False towerVisible=True

容量边界

当前结构按对象保存一个票据,签发和回读均摊为 O(1);相机切换扫描已知对象,复杂度为 O(V)。它适用于单一 CPU 提交线程和中等规模可见集,不描述真实 GPU 查询 API,也不处理多视口共享。为每个对象阻塞等待最新结果能消除陈旧回读,却会把 GPU 延迟转成 CPU 气泡,破坏异步设计。当前取舍是允许失效后一段保守过绘制,以换取画面不错误缺失。

当前合同:单提交线程、单相机代际、每对象一个当前票据
失败恢复:拒绝旧结果,对象保持可见,下一批重新查询
失效信号:相机切换时 O(V) 扫描超预算、未决对象持续增长
演进触发:V 超过 100000、立体视图或多个传送门并行查询
下一结构:view key + epoch + object generation + visibility bitset

当可见对象超过十万、多个视图并行或相机切换扫描超预算时,应把字典升级为按视图分区的位图与对象代际数组;但状态发布仍需保留同样的三项谓词。遮挡查询可以延迟完成,却不能延迟拥有权判断:旧 GPU 结果没有资格解释当前相机看到的世界。