瞬态纹理复用:最后一次读取所在的 Pass 仍占用存储
一次横向模糊读取 A、写出 B,接着纵向模糊读取 B、写出 C。三张中间纹理规格相同,资源池看起来只需找一块“已经不用”的存储。危险恰好藏在“已经”两个字里:横向模糊开始使用 B 时,A 虽然进入最后一次使用,却仍是当前 Pass 的输入。若立即把 A 的存储交给 B,计划表节省了容量,读写内容却发生重叠。

这里用一条四个 Pass 的固定链条把边界说清:Pass 0 产生 A,Pass 1 读取 A 并完整写入 B,Pass 2 读取 B 并完整写入 C,Pass 3 消费 C。所有纹理都是单层、单 Mip、单采样的二维 RGBA16F,尺寸为 1024×1024;所有访问已经收集完整,且执行在同一有序队列。本文的 C# 小项目验证分配计划,不调用引擎或图形驱动,也不把 CPU 检查冒充画面验收。
Pass 1 需要两张同时活着的纹理
生命周期用闭区间 [First, Last] 表示,从首次使用覆盖到最后一次使用所在的整个 Pass。于是 A 是 [0,1],B 是 [1,2],C 是 [2,3]。A 与 B 在 1 相交,B 与 C 在 2 相交;A 与 C 没有交集。正确结果是两个存储槽位交替使用:A 放入槽位 0,B 放入槽位 1,C 再使用槽位 0。
对一般模糊核,输出像素会读取邻近输入,不能假设某个位置写完后其他位置就不会再读它。当前分配器也没有 Pass 内部的访问顺序信息,所以它必须把整个 Pass 作为不可细分的占用单位。即使个别算法支持原地处理,也应由专门的访问合同证明,不能从“输入最后用一次”推断出来。
两条区间相交的判定是 max(firstA, firstB) <= min(lastA, lastB)。按首次使用排序后,可复用条件相应为 previous.Last < next.First。这里少一个等号,保证的是同一个 Pass 的两个角色仍有不同存储;它与纹理句柄跨帧是否有效是两项独立检查。
分配计划保留逻辑名字,复用物理槽位
$PROBE_PROJECT/Allocator.cs 中的 Allocator.Compile 接收已分析完成的生命周期,返回逻辑资源到槽位的映射。$PROBE_PROJECT 指本篇独立的 TransientTextureLab 项目目录。下面是该文件的完整实现:
public sealed record TextureSpec(int Width, int Height, string Format)
{
public long Bytes => checked((long)Width * Height * (Format == "RGBA16F" ? 8 : 4));
}
public sealed record Lifetime(string Name, int First, int Last, TextureSpec Spec);
public sealed record Assignment(Lifetime Resource, int Slot);
public sealed record Slot(TextureSpec Spec, int Last);
public sealed record Allocation(Assignment[] Assignments, long Bytes);
public static class Allocator
{
public static Allocation Compile(IReadOnlyList<Lifetime> resources)
{
if (resources.Any(r => string.IsNullOrWhiteSpace(r.Name) || r.First < 0 ||
r.Last < r.First || r.Spec.Width <= 0 || r.Spec.Height <= 0 ||
r.Spec.Format is not ("RGBA16F" or "RGBA8")) ||
resources.Select(r => r.Name).Distinct(StringComparer.Ordinal).Count() != resources.Count)
throw new ArgumentException("Invalid lifetime or texture descriptor");
var slots = new List<Slot>();
var assignments = new List<Assignment>();
foreach (var r in resources.OrderBy(r => r.First).ThenBy(r => r.Name, StringComparer.Ordinal))
{
int slot = slots.FindIndex(s => s.Spec == r.Spec && s.Last < r.First);
if (slot < 0)
{
slot = slots.Count;
slots.Add(new Slot(r.Spec, r.Last));
}
else slots[slot] = new Slot(r.Spec, r.Last);
assignments.Add(new Assignment(r, slot));
}
return new Allocation(assignments.ToArray(), slots.Sum(s => s.Spec.Bytes));
}
}
按 First 排序后,同规格且上一个占用已结束的槽位可以直接接纳新资源;否则增加槽位。以名字作次级排序,让同起点的输入交换枚举顺序后仍得到相同映射。这个实现线性查找可用槽位,最坏编译复杂度为 O(n²),没有测量大图编译速度;当前切片优先让兼容性与区间检查能被逐项核对。
描述符完全相等才允许复用,意味着不同尺寸或 RGBA8 与 RGBA16F 即使生命周期分离也进入不同槽位。这比比较总字节数保守:两个字节数相等的资源未必拥有相同布局或使用能力。示例仅支持两种格式并固定其他维度。实际资源池还必须把采样数、Mip、数组层数和用途等约束纳入兼容键,不能直接把这里的三个字段当成完整引擎描述符。
A、B、C 的逻辑名字始终保留。把 C 映射到槽位 0,不意味着 C 继承 A 的内容。当前前提要求首次使用完整写入;如果 C 要通过 load 或局部写保留旧内容,就不能套用这份丢弃旧内容的合同。跨帧历史纹理和外部导入资源也不进入该瞬态列表。

每张纹理的有效载荷为 1024 × 1024 × 8 = 8,388,608 字节,即 8 MiB。三份独立存储是 24 MiB,合法计划需要 16 MiB。因为 Pass 1 确实同时需要两张同规格纹理,两份也是这个固定案例的下界。这里没有计入驱动对齐、压缩或资源元数据,不能把 8 MiB 差额报告成已经测得的显存节省。
用错误端点检验容量数字
Program.SharedPassCannotAlias 同时检查正确映射和一个故意错误的分配器。错误版本使用 last <= r.First,把三项都放入槽位 0。Program.Conflicts 再对同槽位资源两两求闭区间交集,发现 A/B 与 B/C 两组冲突。只断言“总容量更小”会把这个错误版本当作优化成功;断言生命周期不重叠才能识别它。
对应回归的关键断言如下,固定输入 Fixed 就是上文三条区间:
var p = Allocator.Compile(Fixed);
Require(p.Assignments.Select(a => a.Slot).SequenceEqual([0, 1, 0]));
Require(p.Bytes == 16777216);
Require(Conflicts(Fixed, UnsafeEndpointPlan(Fixed)) == 2);
2026-09-05 使用 .NET SDK 9.0.202、Release 配置执行。构建退出码 0,警告与错误均为 0;下列测试命令退出码同样为 0。耗时是单次检查程序内部计时,包含控制台输出,不代表分配器基准性能。
dotnet build "$PROBE_PROJECT/TransientTextureLab.csproj" -c Release --no-restore
dotnet run --project "$PROBE_PROJECT/TransientTextureLab.csproj" -c Release --no-build
fixed A=[0,1] B=[1,2] C=[2,3] slots=0,1,0
naive_bytes=25165824 pooled_bytes=16777216 unsafe_bytes=8388608 unsafe_conflicts=2
interval_pairs=225
passed=10 failed=0 skipped=0 elapsed_ms=53.911
ExhaustivePairs 在 Pass 0 到 4 上枚举所有合法闭区间,共 15 种,两两组合为 225 组;它逐组比较分配器是否共享槽位与数学上的区间相交结果。其余检查覆盖同一 Pass 的零跨度生命周期、不相交区间复用、格式与尺寸不兼容、反向区间、重复名字、非法尺寸以及输入顺序变化。范围非法时整个调用抛出异常,不返回部分计划;调用者仍须自行决定如何保留旧帧或停止提交。
有序列表不能代替 GPU 依赖
这个结果成立于已确定的单队列顺序。执行器需要保证前一资源的全部访问在复用前完成,并建立相应的资源状态转换或内存依赖。示例只计算计划,没有提交 barrier,也没有实现底层堆内存别名。若以同一纹理对象复用槽位,仍须满足访问同步;若改为不同对象共享底层内存,还要满足所用图形接口的别名约束。
加入异步计算后,CPU 上 Pass 1 排在 Pass 2 前面不再足以证明设备执行关系。复用判定必须来自队列间真实的先后依赖,或者保守地把可能并行的使用区间视为重叠。缺少这份证明时保留独立存储,比用一个未经成立的总序估算容量更可靠。
回到模糊链条,A 可以给 C 腾出槽位,却不能在 Pass 1 开始时先让给 B。容量评审应先审每个槽位的占用证明,再看合计字节数;两张纹理的方案在本例达到下界,一张纹理的方案则明确违反输入仍需存活的条件。