兴趣范围内有 200 个对象,也不能把它们全塞进同一帧快照

玩家站在战场中央,空间索引返回 200 个“可见对象”。如果快照构建器把这 200 个对象依次编码,单帧载荷会随局部密度增长;若它写满缓冲区才停止,包尾可能只留下半个对象。空间筛选没有出错,它回答的是“谁与这条连接相关”,却没有回答“这一帧能完整交付谁”。

每连接兴趣对象经过有限快照预算形成网络提交批次

我更倾向把字节预算放进每条连接的快照所有者,而不是空间索引。索引可以被许多连接共享,看到的是世界;预算属于单条连接,取决于它本帧允许发送的载荷。两者合并后,热点区域里一条低预算连接会反过来改变共享查询结果,也让同一对象集合难以被其他连接复用。

相关不等于本帧入选

探针 InterestBudget.cs 的入口是 ConnectionSnapshotOwner.BeginRequest,候选从 BuildCandidate 进入,编码结果最后交给 TryCommit。固定输入有四个对象:101、205、309、412,编码后分别占 52、44、36、20 字节;单帧载荷预算为 120 字节。

选择顺序先比较玩法优先级,再比较距离,最后以实体 ID 打破完全相同的排序键。最后一项不是业务优先级,却是确定性合同:同一输入不能因容器枚举顺序变化而产生不同快照。对象 309 比 412 更近,但优先级更高的 101 与 205 已占 96 字节,剩余 24 字节装不下 309;算法继续检查后续对象,最终让 20 字节的 412 入选,批次使用 116 字节。

public sealed class ConnectionSnapshotOwner
{
    private long requestedGeneration;

    public SnapshotBatch Active { get; private set; } = SnapshotBatch.Empty;

    public long BeginRequest() => ++requestedGeneration;

    public SnapshotBatch BuildCandidate(
        long generation,
        int byteBudget,
        IEnumerable<EntityState> visible)
    {
        if (byteBudget < 0)
            throw new ArgumentOutOfRangeException(nameof(byteBudget));

        var selected = new List<int>();
        var usedBytes = 0;
        foreach (var entity in visible
                     .OrderByDescending(item => item.Priority)
                     .ThenBy(item => item.DistanceMeters)
                     .ThenBy(item => item.Id))
        {
            if (entity.EncodedBytes <= 0)
                throw new InvalidDataException($"invalid-size:{entity.Id}");

            if (usedBytes + entity.EncodedBytes > byteBudget)
                continue;

            selected.Add(entity.Id);
            usedBytes += entity.EncodedBytes;
        }

        return new SnapshotBatch(generation, selected, usedBytes);
    }

    public bool TryCommit(SnapshotBatch candidate, bool encodeSucceeded)
    {
        if (!encodeSucceeded || candidate.Generation != requestedGeneration)
            return false;

        Active = candidate;
        return true;
    }
}

这里故意使用“装不下就继续”,而非第一次超额便结束。排序定义的是价值次序,不代表对象尺寸单调;36 字节对象失败后,20 字节对象仍可利用剩余空间。它不是背包问题的最优解,只是一个可解释、确定且线性扫描的启发式。若产品要求最大化综合价值,选择器可以替换,但预算与原子提交边界不应随之消失。

预算还要明确包含范围。若 120 字节只允许实体载荷,传输头应在进入选择器前扣除;若它表示完整数据报上限,候选尺寸就必须连同快照头和每对象标识一起计量。把两种口径混用,会让测试里的 116 字节合法,线上封包后却稳定超限。这个口径应属于连接协议,而不是由某个实体序列化器临时猜测。

固定 120 字节预算下的候选选择与原子提交关系

编码失败不能发布半张列表

选择完成不代表快照可见。序列化器可能发现组件数据非法,压缩阶段也可能失败;与此同时,新一帧可能已经启动了代次更高的构建。若候选在编码过程中逐项写入 Active,读者会看到对象列表、字节数和代次来自不同批次。

因此 TryCommit 同时检查编码结果与请求代次。任一条件不满足,活动快照保持原引用。固定失败样本让第二代候选只包含 101、205,但传入 encodeSucceeded=false 后提交返回 false,活动批次仍是上一轮的 101、205、412。陈旧代次也经过相同提交点,不需要在每个异步回调里复制回滚逻辑。

当天验证命令与决定性输出如下,项目路径使用工作目录占位符:

$ dotnet build $WORK_DIR/.tmp/daily-labs/interest-budget/InterestBudgetProbe.csproj -c Release
Build succeeded.
0 Warning(s)
0 Error(s)
exit code: 0

$ dotnet run --project $WORK_DIR/.tmp/daily-labs/interest-budget/InterestBudgetProbe.csproj -c Release --no-build
SAMPLE budget=120 selected=[101,205,412] used=116 committed=True
FAILURE encode=false candidate=[101,205] committed=False active=[101,205,412]
RESULT passed=8 failed=0 skipped=0 elapsed_ms=49
exit code: 0

八个检查还覆盖了预算不越界、超大对象跳过、ID 稳定排序、成功提交、陈旧代次拒绝和非法尺寸拒绝。这里没有给出吞吐或延迟结论;样本只证明选择与提交语义,不能替代真实连接规模下的负载测试。

预算必须计量真正发送的字节

当前探针把 EncodedBytes 作为已知输入,适合验证所有权与失败边界。生产实现若在选择前用结构体大小估算,却在选择后加入变长整数、组件掩码、包头或压缩字典,UsedBytes <= byteBudget 仍可能与真实载荷不一致。可靠做法只有两种:使用与编码器一致的精确尺寸函数,或者先编码到候选缓冲区,再以最终长度裁决提交。

本方案也没有解决跨帧公平性。持续高优先级对象可能让低优先级对象长期饥饿;当监控出现对象最长未发送帧数持续增长时,应在排序键中加入年龄或保底配额。那是选择策略的演进信号,不是放宽预算的理由。

回到开头的 200 个相关对象:空间索引可以完整返回它们,但单条连接只公开一份经过字节预算裁决、完整编码且代次仍有效的快照。这样高密度区域造成的是有规则的降级,而不是超大包、半对象或异步旧结果覆盖当前状态。