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);

GPU 可见实例容量与溢出主视觉

批次边界

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。