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 结果没有资格解释当前相机看到的世界。