渲染图瞬态纹理句柄:代际校验阻断跨帧误用

一帧后处理通常先写半分辨率 Bloom,再把它与场景色合成。危险不在 API 调用本身,而在上一帧句柄仍是一个合法整数:如果资源池已复用同一槽位,旧句柄可能静默指向另一张纹理。本探针把句柄定义为“索引 + 创建代际”,并规定渲染图独占代际、纹理表和 Pass 表。

// .tmp/daily-labs/2026-07-26-1930/RenderGraph.cs
public readonly record struct TextureHandle(int Index, int Generation);

public sealed record TextureDescriptor(
    string Name,
    int Width,
    int Height,
    int BytesPerPixel
)
{
    public long ByteSize =>
        checked((long)Width * Height * BytesPerPixel);
}

public sealed record PassDeclaration(
    string Name,
    IReadOnlyList<TextureHandle> Reads,
    IReadOnlyList<TextureHandle> Writes
);

渲染图瞬态资源与过期句柄的代际边界

所有权边界

TextureHandle 不拥有纹理,只是本代纹理描述的能力票据;RenderGraph 才能推进代际并清空瞬态表。BeginFrame 之后,旧句柄必须失效。这个约束把跨帧历史资源排除在瞬态接口之外:需要 TAA 历史或累积缓冲时,应由更长生命周期的资源所有者显式导入,而不是保留旧索引。

// .tmp/daily-labs/2026-07-26-1930/RenderGraph.cs
private readonly Dictionary<int, TextureDescriptor> _textures = [];
private readonly List<PassDeclaration> _passes = [];
private int _generation;

public void BeginFrame()
{
    _generation++;
    _textures.Clear();
    _passes.Clear();
}

public TextureHandle CreateTexture(TextureDescriptor descriptor)
{
    if (descriptor.Width <= 0 ||
        descriptor.Height <= 0 ||
        descriptor.BytesPerPixel <= 0)
    {
        throw new ArgumentOutOfRangeException(nameof(descriptor));
    }

    var index = _textures.Count;
    _textures.Add(index, descriptor);
    return new TextureHandle(index, _generation);
}

声明时校验

正常时序是创建两张本代纹理,声明 BloomExtract 的读写集合,再声明 Composite,最后统一执行。关键不变量是:一个 Pass 的全部句柄先解析成功,才能进入 _passes。若边校验边写表,后半组句柄失败会留下半声明节点,后续拓扑、屏障和显存估算都将建立在损坏状态上。

// .tmp/daily-labs/2026-07-26-1930/RenderGraph.cs
public void AddPass(PassDeclaration pass)
{
    foreach (var handle in pass.Reads.Concat(pass.Writes))
    {
        Resolve(handle);
    }

    _passes.Add(pass);
}

public IReadOnlyList<ExecutionRecord> Execute()
{
    var records = new List<ExecutionRecord>(_passes.Count);
    foreach (var pass in _passes)
    {
        var readBytes = pass.Reads.Sum(
            handle => Resolve(handle).ByteSize
        );
        var writeBytes = pass.Writes.Sum(
            handle => Resolve(handle).ByteSize
        );
        records.Add(new ExecutionRecord(
            pass.Name,
            readBytes,
            writeBytes
        ));
    }

    return records;
}

过期失败

失败时序先在代际 1 创建 History,随后 BeginFrame 推进到代际 2,再尝试用旧句柄声明 TemporalResolve。解析器比较代际后立即抛错;当前帧已经创建的 Color 仍保留,Pass 数量仍为零。失败因此是原子的,调用方可以放弃本帧构图或改用无历史降级路径。

// .tmp/daily-labs/2026-07-26-1930/Program.cs
var graph = new RenderGraph();
graph.BeginFrame();
var previous = graph.CreateTexture(
    new("History", 1280, 720, 4)
);

graph.BeginFrame();
var current = graph.CreateTexture(
    new("Color", 1280, 720, 4)
);

Throws<InvalidOperationException>(() =>
    graph.AddPass(new(
        "TemporalResolve",
        [previous],
        [current]
    ))
);

Equal(1, graph.TextureCount);
Equal(0, graph.Execute().Count);

固定输入下的 Pass 字节流、代际推进与原子失败

固定数据

固定输入使用 1920×1080 RGBA8 场景色和 960×540 RGBA8 Bloom。单张字节数分别为 8,294,4002,073,600BloomExtract 读取前者、写入后者;Composite 同时读取两张纹理并回写场景色。这里统计的是声明覆盖的逻辑字节量,不是 GPU 实测带宽,也不包含缓存、压缩、Tile 或别名复用。

$ dotnet run --project \
  $WORK_DIR/.tmp/daily-labs/2026-07-26-1930/RenderGraphProbe.csproj \
  -c Release
{
  "generation": 1,
  "textures": 2,
  "passes": [
    {
      "Pass": "BloomExtract",
      "ReadBytes": 8294400,
      "WriteBytes": 2073600
    },
    {
      "Pass": "Composite",
      "ReadBytes": 10368000,
      "WriteBytes": 8294400
    }
  ],
  "tests": {
    "passed": 3,
    "failed": 0,
    "skipped": 0
  },
  "elapsedMs": 35.1405
}
exit_code=0
real=3.85s

架构取舍

代际校验只增加句柄中的一个整数和声明期比较,却主动禁止跨帧复用瞬态句柄。代价是持久纹理必须走显式导入接口,但这正好暴露真正的生命周期差异。当前实现按声明顺序执行,适合验证所有权合同;当 Pass 数量增长到需要自动排序,或峰值显存要求纹理别名时,应在相同句柄合同之上增加依赖图和生命周期区间,而不是放宽代际。

触发条件 演进方向 保留不变量
TAA 或累积缓冲跨帧存在 引入持久资源导入 瞬态句柄仍不得跨代
Pass 依赖不再线性 拓扑排序与环检测 声明完整后才入图
峰值显存需要压缩 生命周期区间与别名分配 解析仍验证代际
异步计算队列启用 队列所有权与显式屏障 失败不得留下半声明

适用边界

探针没有创建真实 GPU 资源,也没有证明驱动带宽或执行性能;它证明的是代际、声明顺序和失败保持。工程上最重要的结论是:资源池复用属于实现细节,句柄有效期必须由渲染图公开表达。只要过期引用能在写入图结构前被拒绝,后续拓扑、屏障、别名和多队列调度才有可信输入。