渲染图瞬态纹理句柄:代际校验阻断跨帧误用
一帧后处理通常先写半分辨率 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);

固定数据
固定输入使用 1920×1080 RGBA8 场景色和 960×540 RGBA8 Bloom。单张字节数分别为 8,294,400 与 2,073,600。BloomExtract 读取前者、写入后者;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 资源,也没有证明驱动带宽或执行性能;它证明的是代际、声明顺序和失败保持。工程上最重要的结论是:资源池复用属于实现细节,句柄有效期必须由渲染图公开表达。只要过期引用能在写入图结构前被拒绝,后续拓扑、屏障、别名和多队列调度才有可信输入。