渲染资源延迟释放:代际租约必须阻断槽位误删

场景分块退出视野后,渲染线程把资源释放命令排入帧尾队列;同帧内槽位又被 Boss 场景复用。若队列只保存整数槽位,旧命令执行时会删除新资源。证据标识 2026-08-01-2000 的 C# 探针把句柄定义为槽位与代际的组合。

// .tmp/daily-labs/2026-08-01-2000/RenderLeaseRegistry.cs
// RenderHandle / RenderLeaseRegistry fields
public readonly record struct RenderHandle(int Slot, int Generation);

public sealed class RenderLeaseRegistry
{
    private sealed record Entry(int Generation, string Owner);

    private readonly Dictionary<int, Entry> active = [];
    private readonly Dictionary<int, int> generations = [];
    private readonly Queue<RenderHandle> deferredReleases = [];
    private readonly Stack<int> freeSlots = [];
    private int nextSlot;
}

所有权边界

注册表唯一拥有槽位分配、当前代际和活动 owner;调用方只持有不可变 RenderHandle;延迟队列只拥有释放意图,不能直接解释槽位。两个不变量是:活动槽位至多对应一个代际;释放仅在槽位与代际同时匹配时生效。设备后端真正销毁 RID 或 GPU 对象属于边界外动作,必须位于匹配成功之后。

渲染资源槽位与两代句柄的主题主视觉

用全局递增 ID 并永不复用也能避免误删,却把长期运行的标识空间和诊断表持续放大;只检查 owner 字符串则把正确性依赖交给可变业务命名。每槽位代际只增加一个整数比较,直接绑定被复用的物理位置。

分配与释放

Allocate 优先复用空闲槽,并在写入活动表前增加该槽位代际。ReleaseNow 是唯一删除入口;延迟释放与立即释放最终都经过它,因此不存在一条绕过代际校验的兄弟路径。

// .tmp/daily-labs/2026-08-01-2000/RenderLeaseRegistry.cs
// Allocate / ReleaseNow / DrainDeferred
public RenderHandle Allocate(string owner)
{
    var slot = freeSlots.TryPop(out var reused) ? reused : nextSlot++;
    var generation = generations.GetValueOrDefault(slot) + 1;
    generations[slot] = generation;
    active.Add(slot, new Entry(generation, owner));
    return new RenderHandle(slot, generation);
}

public bool ReleaseNow(RenderHandle handle)
{
    if (!active.TryGetValue(handle.Slot, out var entry) ||
        entry.Generation != handle.Generation)
        return false;

    active.Remove(handle.Slot);
    freeSlots.Push(handle.Slot);
    return true;
}

public (int Released, int Stale) DrainDeferred()
{
    var released = 0;
    var stale = 0;
    while (deferredReleases.TryDequeue(out var handle))
    {
        if (ReleaseNow(handle)) released++;
        else stale++;
    }
    return (released, stale);
}

正常时序是分配、排队、帧尾匹配、释放;失败时序是旧代际排队、立即释放、槽位复用、帧尾拒绝旧命令。拒绝不是异常恢复,而是队列消费的合法结果,因此返回统计而非抛出异常。

复用回归

回归测试故意制造最危险的顺序:先保存 gen 1 的延迟命令,再立即释放并把同一槽位分给 gen 2。队列清空后必须得到 released=0, stale=1,并且当前 owner 仍为 boss-arena。另一个用例伪造未来代际,证明“代际不同”一律不能修改活动表。

// .tmp/daily-labs/2026-08-01-2000/RenderLeaseRegistryTests.cs
// StaleReleaseCannotFreeReusedSlot / ForgedGenerationIsRejected
public static void StaleReleaseCannotFreeReusedSlot()
{
    var registry = new RenderLeaseRegistry();
    var old = registry.Allocate("terrain-a");
    registry.DeferRelease(old);
    True(registry.ReleaseNow(old));
    var current = registry.Allocate("boss-arena");
    Equal(old.Slot, current.Slot);
    Equal(old.Generation + 1, current.Generation);
    Equal((0, 1), registry.DrainDeferred());
    Equal("boss-arena", registry.OwnerOf(current));
}

public static void ForgedGenerationIsRejected()
{
    var registry = new RenderLeaseRegistry();
    var current = registry.Allocate("hud-atlas");
    var forged = current with { Generation = current.Generation + 4 };
    True(!registry.ReleaseNow(forged));
    Equal("hud-atlas", registry.OwnerOf(current));
}

槽位复用后旧代际释放被拒绝的时序图

固定行为

探针使用 .NET 9 标准库,不引入外部包。Release 构建为 0 警告、0 错误;三个用例全部通过,测试逻辑耗时 5.841 毫秒,完整运行进程耗时 0.78 秒,退出码为 0。这些数字只证明当前固定输入的行为,不用于声称引擎帧耗改善。

$ cd "$WORK_DIR/.tmp/daily-labs/2026-08-01-2000"
$ dotnet restore --ignore-failed-sources
$ dotnet build -c Release --no-restore
Build succeeded.
    0 Warning(s)
    0 Error(s)
$ /usr/bin/time -p dotnet run -c Release --no-build --no-restore
PASS MatchingDeferredReleaseFreesSlot
PASS StaleReleaseCannotFreeReusedSlot
PASS ForgedGenerationIsRejected
tests=3 passed=3 failed=0 skipped=0 duration_ms=5.841
real 0.78
user 0.33
sys 0.17
exit_code=0
{
  "evidenceId": "2026-08-01-2000",
  "oldHandle": { "slot": 0, "generation": 1 },
  "currentHandle": { "slot": 0, "generation": 2 },
  "drain": { "released": 0, "stale": 1 },
  "currentOwner": "boss-arena",
  "activeCount": 1
}

容量边界

分配、立即释放和单条队列消费的字典操作平均为 O(1);清空 Q 条释放命令为 O(Q),活动表空间为 O(A),其中 A 是活动资源数。当前整数代际在进程生命周期内不回绕;若协议把代际压缩为 16 位,65536 次同槽复用就会重新碰撞,必须改用更宽字段或附加设备 epoch。

正常:allocate gen 1 -> defer gen 1 -> drain -> release gen 1
失败:defer gen 1 -> release -> allocate gen 2 -> reject gen 1

当前边界:单进程注册表,帧尾队列,32 位有符号代际
演进触发:设备丢失、跨线程销毁、代际压缩、跨进程句柄传递
下一结构:device epoch + generation + 后端销毁完成栅栏

多个生产线程直接调用后端销毁看似减少一层队列,却会失去渲染线程顺序和完成栅栏。当前方案保留单消费者队列,以陈旧命令计数作为生命周期竞态信号;当 stale 持续增长时,应检查重复释放或资源切换节奏,而不是取消代际门。

架构结论

渲染资源槽位可以复用,释放权限不能随槽位号自动转移。句柄必须携带代际,所有释放路径必须汇入同一个匹配入口,延迟队列执行时再次确认当前 owner。该合同解决的是旧销毁命令误伤新资源,与“旧句柄不能读取新资源”互补;设备重建或跨线程后端出现时,再把设备 epoch 与完成栅栏并入同一租约即可。