GPU 可见实例压缩:列表与间接绘制计数必须同批提交
开放世界植被与碎片渲染常把视锥筛选结果压入固定容量缓冲,再由间接绘制参数读取实例数。问题不是“最多画多少”,而是列表和计数若分两次可见,GPU 可能按新计数读取旧列表尾部。证据标识 2026-08-07-1930,以下探针把排序、截断、上传与发布收敛为一条可复算调用链。
// .tmp/daily-labs/2026-08-07-1930/VisibleInstanceBatch.cs
// VisibleCandidate, BuildTicket
public readonly record struct VisibleCandidate(
int InstanceId,
float Distance);
public readonly record struct BuildTicket(
int Generation,
int[] InstanceIds,
int SourceCount,
int DroppedCount);

批次边界
VisibleInstanceBatch 独占已发布实例 ID 与 DrawCount;视锥阶段只提交候选,上传回调只携带代际和成功状态。两个不变量是:DrawCount 必须等于已发布数组长度;上传失败或回调过期时,两者都不能变化。候选距离单位为米,距离相同再按实例 ID 升序,使容量溢出时的牺牲者可复现。
系统边界停在 CPU 生成的上传票据:探针不模拟计算着色器、命令缓冲或驱动栅栏,但明确要求真实渲染器只能在上传完成事件之后调用提交。实例变换、包围体和材质索引应由同一实例 ID 解析,不能让列表只更新其中一类属性,否则计数一致仍可能引用跨帧数据。
// .tmp/daily-labs/2026-08-07-1930/VisibleInstanceBatch.cs
// VisibleInstanceBatch.Prepare
public BuildTicket Prepare(
IEnumerable<VisibleCandidate> candidates,
int capacity)
{
if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity));
var ordered = candidates
.OrderBy(item => item.Distance)
.ThenBy(item => item.InstanceId)
.ToArray();
var ids = ordered
.Take(capacity)
.Select(item => item.InstanceId)
.ToArray();
_pending = new BuildTicket(
++_generation, ids, ordered.Length,
Math.Max(0, ordered.Length - ids.Length));
return _pending.Value;
}
容量裁决
固定输入含 7 个可见候选,容量为 4。排序键 (distance, instanceId) 产生 11,17,29,23,31,37,41,因此只保留前四项并明确记录 DroppedCount=3。这里选择近距优先而不是“线程先到先得”:后者会把 GPU 调度时序写进画面,既无法回放,也会造成相机静止时实例闪烁。
| 容量 | 来源 | 已选择 | 丢弃 | 已发布计数 |
|---|---|---|---|---|
| 4 | 7 | 11, 17, 29, 23 | 3 | 4 |
原子发布
准备阶段不得改活动批次。只有匹配当前代际且上传成功,才同时替换数组和计数;这对应正常时序 Prepare → buffer upload → CompleteUpload → indirect draw。如果先写 DrawCount=4,而活动数组仍只有两个元素,间接绘制就会越界解释未定义实例数据。
// .tmp/daily-labs/2026-08-07-1930/VisibleInstanceBatch.cs
// VisibleInstanceBatch.CompleteUpload
public bool CompleteUpload(int generation, bool uploadSucceeded)
{
if (_pending is not { } pending ||
pending.Generation != generation)
return false;
if (!uploadSucceeded)
{
_pending = null;
return false;
}
PublishedIds = pending.InstanceIds;
DrawCount = pending.InstanceIds.Length;
_pending = null;
return true;
}

失败退化
失败路径分为上传失败与陈旧完成。上传失败清除 pending,但保持旧数组 [3,5] 和计数 2;新一代准备覆盖旧 pending 后,第 1 代完成必须返回 false。恢复方式是下一帧重新筛选,而不是修改旧批次填补空位,因为旧列表仍是唯一完整且可绘制的数据。
// .tmp/daily-labs/2026-08-07-1930/Program.cs
// stale-upload-cannot-replace-newer-build
Run("stale-upload-cannot-replace-newer-build", () =>
{
var batch = NewBatch();
var stale = batch.Prepare(Candidates(), 4);
var current = batch.Prepare(Candidates(), 3);
False(batch.CompleteUpload(stale.Generation, true));
EqualArray(new[] { 3, 5 }, batch.PublishedIds);
True(batch.CompleteUpload(current.Generation, true));
EqualArray(new[] { 11, 17, 29 }, batch.PublishedIds);
Equal(3, batch.DrawCount);
});
运行证据
探针使用 .NET 9 Release,无外部包。最终构建和运行退出码均为 0,3 项通过、0 失败、0 跳过;耗时只描述进程内排序与断言,不能外推为 GPU 剔除性能。
$ cd $WORK_DIR/.tmp/daily-labs/2026-08-07-1930
$ dotnet build VisibleInstanceProbe.csproj \
-c Release --nologo --no-restore
Build succeeded.
0 Warning(s)
0 Error(s)
Time Elapsed 00:00:03.84
exit=0
$ dotnet run --project VisibleInstanceProbe.csproj \
-c Release --no-build
PASS capacity-truncates-by-stable-priority
PASS failed-upload-keeps-published-batch
PASS stale-upload-cannot-replace-newer-build
tests: passed=3 failed=0 skipped=0 elapsedMs=29.0400
before: ids=[3,5] drawCount=2 pendingGeneration=1
after: ids=[11,17,29,23] drawCount=4 pending=null
capacity=4 source=7 dropped=3
exit=0
工程边界
该结构适合一个相机、一个材质批次和单个在途上传。容量持续溢出是扩容或分桶信号;多相机、多个 LOD 或材质组出现后,应按批次键分区并为每区维护独立代际。固定数组会牺牲远端实例,但换来确定的内存上界和失败退化;无界追加虽保留全部候选,却把峰值压力推给显存与间接参数。
运行期应分别记录来源数、丢弃数、上传失败数和陈旧完成数。偶发丢弃可以是预算策略,连续多帧接近容量则说明空间分区或批次粒度失配;陈旧完成持续增长则说明上传队列已积压。只有这些信号稳定出现,才值得把单缓冲提升为多帧环形槽,而不是预先引入更复杂的同步结构。
架构结论是:可见性结果不是一个计数器,而是实例数据与 draw count 的不可分批次。只要排序规则、容量和代际都进入票据,溢出就表现为可观测的确定性降级,上传失败也只会继续绘制上一完整批次,而不会把半成品交给 GPU。