渲染图纹理句柄为什么不能跨帧保存

一段后处理代码把渲染图返回的颜色纹理句柄存进组件字段,下一帧继续交给描边 Pass。句柄里的槽位编号仍是 0,底层资源池也恰好把槽位 0 分给了另一张速度纹理,于是调用没有立刻崩溃,画面却读到了语义完全不同的内容。这里最危险的不是“资源已经释放”,而是旧标识仍能命中新对象。

相邻帧中的瞬态纹理与资源槽

仅把瞬态资源包装成整数句柄,并不能建立生命周期边界。句柄必须回答两个问题:它属于哪一帧,以及它指向的槽位是否仍是创建时那一代。帧号阻止跨帧使用;槽位代次阻止同一帧内释放后复用。缺少任意一个维度,旧身份都可能重新变得“有效”。

槽位相同不代表资源相同

探针把纹理句柄定义为 (FrameId, Slot, Generation)。注册表是唯一状态所有者:它推进当前帧、分配或复用槽位,并在每次创建时递增槽位代次。调用者只能保存值类型句柄,不能取得可绕过校验的槽位引用。

FrameResourceRegistry.cs 中的核心实现如下:

public readonly record struct TextureHandle(int FrameId, int Slot, int Generation);
public readonly record struct TextureResource(string Name, int Width, int Height);

public enum ResolveStatus
{
    Resolved,
    WrongFrame,
    Released,
    ReusedSlot,
    InvalidSlot
}

public readonly record struct ResolveResult(
    ResolveStatus Status,
    TextureResource? Resource);

public sealed class FrameResourceRegistry
{
    private sealed class SlotState
    {
        public int Generation { get; set; }
        public TextureResource? Resource { get; set; }
    }

    private readonly List<SlotState> slots = [];
    private readonly Stack<int> freeSlots = [];

    public int CurrentFrame { get; private set; }

    public void BeginFrame(int frameId)
    {
        if (frameId <= CurrentFrame)
            throw new ArgumentOutOfRangeException(nameof(frameId));

        CurrentFrame = frameId;
    }

    public TextureHandle Create(TextureResource resource)
    {
        var slotIndex = freeSlots.Count > 0 ? freeSlots.Pop() : AddSlot();
        var slot = slots[slotIndex];
        slot.Generation++;
        slot.Resource = resource;
        return new TextureHandle(CurrentFrame, slotIndex, slot.Generation);
    }

    public ResolveResult Resolve(TextureHandle handle)
    {
        if (handle.FrameId != CurrentFrame)
            return new ResolveResult(ResolveStatus.WrongFrame, null);

        if ((uint)handle.Slot >= (uint)slots.Count)
            return new ResolveResult(ResolveStatus.InvalidSlot, null);

        var slot = slots[handle.Slot];
        if (handle.Generation != slot.Generation)
            return new ResolveResult(ResolveStatus.ReusedSlot, null);

        return slot.Resource is null
            ? new ResolveResult(ResolveStatus.Released, null)
            : new ResolveResult(ResolveStatus.Resolved, slot.Resource);
    }

    public bool Release(TextureHandle handle)
    {
        if (Resolve(handle).Status != ResolveStatus.Resolved)
            return false;

        slots[handle.Slot].Resource = null;
        freeSlots.Push(handle.Slot);
        return true;
    }

    private int AddSlot()
    {
        slots.Add(new SlotState());
        return slots.Count - 1;
    }
}

解析顺序也有含义。先检查帧号,可以在触碰槽位状态前拒绝整个旧帧的句柄;帧号有效后再检查范围和代次,才允许返回当前资源。失败结果中的 Resource 始终为 null,调用者不能一边收到警告,一边继续使用“尽力解析”的对象。

两道校验覆盖不同的陈旧路径

固定输入先在帧 41 创建 ColorA,得到槽位 0、代次 1,同帧解析成功。推进到帧 42 后,原句柄立即得到 WrongFrame。随后创建并释放 DepthB,资源池再次使用槽位 0 创建 VelocityC,代次变为 2;旧的 DepthB 句柄得到 ReusedSlot,而当前句柄仍解析为 VelocityC

帧号与槽位代次的两重校验

这两个失败不能合并成一次普通空指针检查。跨帧错误说明调用者保存了超出编译图生命周期的身份,通常要修正 Pass 数据传递;代次错误说明释放与复用次序存在问题,通常要检查同帧别名或提前归还。明确分类能把错误定位到合同边界,而不是等 GPU 读到错误格式后才从破图倒推。

测试入口还验证了失败不改变活动槽位:对旧代次的解析只返回状态,不释放、不覆盖,也不推进任何代次。因此拒绝旧句柄之后,VelocityC 仍是槽位 0 的活动资源。这是资源池可以安全复用槽位的前提。

图编译器仍要检查另一层关系:一个 Pass 声明读取的纹理,必须由可达的前序 Pass 写入或从外部导入;写后读、读后写和队列切换再据此生成屏障。那套依赖分析回答“本帧内执行顺序是否合法”,句柄校验回答“提交到解析器的身份是否还属于本帧当前槽位”。编译期发现不了组件字段里缓存的上帧句柄,运行期代次也不能替代缺失的 Pass 依赖,两层检查应在不同边界各自失败。

调试构建可以把 WrongFrame 视为强断言,并记录创建 Pass 与解析 Pass;发布构建则应拒绝该资源并让当前 Pass 采用明确的跳过或回退路径。不能为了“画面尽量出来”而忽略帧号,因为槽位复用后,错误采样可能在格式、尺寸都碰巧兼容时长期潜伏。若统计中反复出现同一调用点的跨帧拒绝,应修正数据流;若代次拒绝集中发生在资源池压力升高时,则要检查释放时机和别名规划,而不是增大句柄有效期。

运行输出证明旧身份没有穿透

当天在 $WORK_DIR/.tmp/daily-labs/2026-08-19-2000 执行:

dotnet build RenderGraphHandleProbe.csproj -c Release --nologo
dotnet run --project RenderGraphHandleProbe.csproj -c Release --no-build

Release 构建退出码为 0,0 警告、0 错误,构建耗时 10.61 秒;运行退出码为 0,外层进程耗时 2.46 秒。8 个断言全部通过,0 失败、0 跳过,决定性输出为:

same-frame: frame=41 slot=0 generation=1 status=Resolved resource=ColorA
cross-frame: handle_frame=41 current_frame=42 status=WrongFrame
slot-reuse: old_generation=1 new_generation=2 status=ReusedSlot active=VelocityC
tests: pass=8 fail=0 skip=0 duration_ms=2

这些耗时只证明最小探针在本次环境完成,不代表渲染图编译或 GPU 执行性能。当前实现也没有模拟真实 GPU 内存别名、Pass 依赖裁剪、异步计算队列和栅栏回收;它验证的是资源身份在 CPU 提交边界上的必要条件。

持久历史纹理、交换链图像或外部相机目标不应伪装成帧域句柄。它们需要独立的导入接口,由每帧渲染图重新建立本帧视图,并把外部资源生命周期留给原所有者。若业务确实要跨帧传递结果,应保存可重新导入的持久资源身份,而不是缓存上一次图编译产生的瞬态槽位。

开篇的描边 Pass 因而不该“延长”旧句柄寿命。它应在当前帧通过图构建数据取得当前纹理句柄;注册表则以帧号和槽位代次共同裁决解析资格。这样槽位可以继续高效复用,但任何旧身份都只会形成明确、无副作用的失败,不会悄悄连接到下一张纹理。